0%

Python

15.4 Scrapyd 批量部署

我们在上一节实现了 Scrapyd 和 Docker 的对接,这样每台主机就不用再安装 Python 环境和安装 Scrapyd 了,直接执行一句 Docker 命令运行 Scrapyd 服务即可。但是这种做法有个前提,那就是每台主机都安装 Docker,然后再去运行 Scrapyd 服务。如果我们需要部署 10 台主机的话,工作量确实不小。 一种方案是,一台主机已经安装好各种开发环境,我们取到它的镜像,然后用镜像来批量复制多台主机,批量部署就可以轻松实现了。 另一种方案是,我们在新建主机的时候直接指定一个运行脚本,脚本里写好配置各种环境的命令,指定其在新建主机的时候自动执行,那么主机创建之后所有的环境就按照自定义的命令配置好了,这样也可以很方便地实现批量部署。 目前很多服务商都提供云主机服务,如阿里云、腾讯云、Azure、Amazon 等,不同的服务商提供了不同的批量部署云主机的方式。例如,腾讯云提供了创建自定义镜像的服务,在新建主机的时候使用自定义镜像创建新的主机即可,这样就可以批量生成多个相同的环境。Azure 提供了模板部署的服务,我们可以在模板中指定新建主机时执行的配置环境的命令,这样在主机创建之后环境就配置完成了。 本节我们就来看看这两种批量部署的方式,来实现 Docker 和 Scrapyd 服务的批量部署。

1. 镜像部署

以腾讯云为例进行说明。首先需要有一台已经安装好环境的云主机,Docker 和 Scrapyd 镜像均已经正确安装,Scrapyd 镜像启动加到开机启动脚本中,可以在开机时自动启动。 接下来我们来看下腾讯云下批量部署相同云服务的方法。 首先进入到腾讯云后台,可以点击更多选项制作镜像,如图 15-3 所示。 图 15-3 制作镜像 然后输入镜像的一些配置信息,如图 15-4 所示。 图 15-4 镜像配置 最后确认制作镜像即可,稍等片刻即可制作成功。 接下来我们可以创建新的主机,在新建主机时选择已经制作好的镜像即可,如图 15-5 所示。 图 15-5 新建主机 后续配置过程按照提示进行即可。 配置完成之后登录新到云主机,即可看到当前主机 Docker 和 Scrapyd 镜像都已经安装好,Scrapyd 服务已经正常运行。 我们就通过自定义镜像的方式实现了相同环境的云主机的批量部署。

2. 模板部署

Azure 的云主机在部署时都会使用一个部署模板,这个模板实际上是一个 JSON 文件,里面包含了很多部署时的配置选项,如主机名称、用户名、密码、主机型号等。在模板中我们可以指定新建完云主机之后执行的命令行脚本,如安装 Docker、运行镜像等。等部署工作全部完成之后,新创建的云主机就已经完成环境配置,同时运行相关服务。 这里提供一个部署 Linux 主机时自动安装 Docker 和运行 Scrapyd 镜像的模板,模板内容太多,源文件可以查看:https://github.com/Python3WebSpider/ScrapydDeploy/blob/master/azuredeploy.json。模板中 Microsoft.Compute/virtualMachines/extensions 部分有一个 commandToExecute 字段,它可以指定建立主机后自动执行的命令。这里的命令完成的是安装 Docker 并运行 Scrapyd 镜像服务的过程。 首先安装一个 Azure 组件,安装过程可以参考:https://docs.azure.cn/zh-cn/xplat-cli-install。之后就可以使用 azure 命令行进行部署。 登录 Azure,这里登录的是中国区,命令如下:

1
azure login -e AzureChinaCloud

如果没有资源组的话需要新建一个资源组,命令如下:

1
azure group create myResourceGroup chinanorth

其中 myResourceGroup 就是资源组的名称,可以自行定义。 接下来就可以使用该模板进行部署了,命令如下:

1
azure group deployment create --template-file azuredeploy.json myResourceGroup myDeploymentName

这里 myResourceGroup 就是资源组的名称,myDeploymentName 是部署任务的名称。 例如,部署一台 Linux 主机的过程如下:

1
2
3
4
5
6
7
8
9
azure group deployment create --template-file azuredeploy.json MyResourceGroup SingleVMDeploy
info: Executing command group deployment create
info: Supply values for the following parameters
adminUsername: datacrawl
adminPassword: DataCrawl123
vmSize: Standard_D2_v2
vmName: datacrawl-vm
dnsLabelPrefix: datacrawlvm
storageAccountName: datacrawlstorage

运行命令后会提示输入各个配置参数,如主机用户名、密码等。之后等待整个部署工作完成即可,命令行会自动退出。然后,我们登录云主机即可查看到 Docker 已经成功安装并且 Scrapyd 服务正常运行。

3. 结语

以上内容便是批量部署的两种方法。在大规模分布式爬虫架构中,如果需要批量部署多个爬虫环境,使用如上方法可以快速批量完成环境的搭建工作,而不用再去逐个主机配置环境。 到此为止,我们解决了批量部署的问题,创建主机完毕之后即可直接使用 Scrapyd 服务。

Python

15.3 Scrapyd 对接 Docker

我们使用了 Scrapyd-Client 成功将 Scrapy 项目部署到 Scrapyd 运行,前提是需要提前在服务器上安装好 Scrapyd 并运行 Scrapyd 服务,而这个过程比较麻烦。如果同时将一个 Scrapy 项目部署到 100 台服务器上,我们需要手动配置每台服务器的 Python 环境,更改 Scrapyd 配置吗?如果这些服务器的 Python 环境是不同版本,同时还运行其他的项目,而版本冲突又会造成不必要的麻烦。 所以,我们需要解决一个痛点,那就是 Python 环境配置问题和版本冲突解决问题。如果我们将 Scrapyd 直接打包成一个 Docker 镜像,那么在服务器上只需要执行 Docker 命令就可以启动 Scrapyd 服务,这样就不用再关心 Python 环境问题,也不需要担心版本冲突问题。 接下来,我们就将 Scrapyd 打包制作成一个 Docker 镜像。

1. 准备工作

请确保本机已经正确安装好了 Docker,如没有安装可以参考第 1 章的安装说明。

2. 对接 Docker

接下来我们首先新建一个项目,然后新建一个 scrapyd.conf,即 Scrapyd 的配置文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[scrapyd]
eggs_dir = eggs
logs_dir = logs
items_dir =
jobs_to_keep = 5
dbs_dir = dbs
max_proc = 0
max_proc_per_cpu = 10
finished_to_keep = 100
poll_interval = 5.0
bind_address = 0.0.0.0
http_port = 6800
debug = off
runner = scrapyd.runner
application = scrapyd.app.application
launcher = scrapyd.launcher.Launcher
webroot = scrapyd.website.Root

[services]
schedule.json = scrapyd.webservice.Schedule
cancel.json = scrapyd.webservice.Cancel
addversion.json = scrapyd.webservice.AddVersion
listprojects.json = scrapyd.webservice.ListProjects
listversions.json = scrapyd.webservice.ListVersions
listspiders.json = scrapyd.webservice.ListSpiders
delproject.json = scrapyd.webservice.DeleteProject
delversion.json = scrapyd.webservice.DeleteVersion
listjobs.json = scrapyd.webservice.ListJobs
daemonstatus.json = scrapyd.webservice.DaemonStatus

在这里实际上是修改自官方文档的配置文件:https://scrapyd.readthedocs.io/en/stable/config.html#example-configuration-file,其中修改的地方有两个:

  • max_proc_per_cpu = 10,原本是 4,即 CPU 单核最多运行 4 个 Scrapy 任务,也就是说 1 核的主机最多同时只能运行 4 个 Scrapy 任务,在这里设置上限为 10,也可以自行设置。
  • bind_address = 0.0.0.0,原本是 127.0.0.1,不能公开访问,在这里修改为 0.0.0.0 即可解除此限制。

接下来新建一个 requirements.txt ,将一些 Scrapy 项目常用的库都列进去,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
requests
selenium
aiohttp
beautifulsoup4
pyquery
pymysql
redis
pymongo
flask
django
scrapy
scrapyd
scrapyd-client
scrapy-redis
scrapy-splash

如果我们运行的 Scrapy 项目还有其他的库需要用到可以自行添加到此文件中。 最后我们新建一个 Dockerfile,内容如下:

1
2
3
4
5
6
7
FROM python:3.6
ADD . /code
WORKDIR /code
COPY ./scrapyd.conf /etc/scrapyd/
EXPOSE 6800
RUN pip3 install -r requirements.txt
CMD scrapyd

第一行 FROM 是指在 python:3.6 这个镜像上构建,也就是说在构建时就已经有了 Python 3.6 的环境。 第二行 ADD 是将本地的代码放置到虚拟容器中,它有两个参数,第一个参数是 . ,即代表本地当前路径,/code 代表虚拟容器中的路径,也就是将本地项目所有内容放置到虚拟容器的 /code 目录下。 第三行 WORKDIR 是指定工作目录,在这里将刚才我们添加的代码路径设成工作路径,在这个路径下的目录结构和我们当前本地目录结构是相同的,所以可以直接执行库安装命令等。 第四行 COPY 是将当前目录下的 scrapyd.conf 文件拷贝到虚拟容器的 /etc/scrapyd/ 目录下,Scrapyd 在运行的时候会默认读取这个配置。 第五行 EXPOSE 是声明运行时容器提供服务端口,注意这里只是一个声明,在运行时不一定就会在此端口开启服务。这样的声明一是告诉使用者这个镜像服务的运行端口,以方便配置映射。另一个用处则是在运行时使用随机端口映射时,会自动随机映射 EXPOSE 的端口。 第六行 RUN 是执行某些命令,一般做一些环境准备工作,由于 Docker 虚拟容器内只有 Python3 环境,而没有我们所需要的一些 Python 库,所以在这里我们运行此命令来在虚拟容器中安装相应的 Python 库,这样项目部署到 Scrapyd 中便可以正常运行了。 第七行 CMD 是容器启动命令,在容器运行时,会直接执行此命令,在这里我们直接用 scrapyd 来启动 Scrapyd 服务。 到现在基本的工作就完成了,运行如下命令进行构建:

1
docker build -t scrapyd:latest .

构建成功后即可运行测试:

1
docker run -d -p 6800:6800 scrapyd

运行之后我们打开:http://localhost:6800 即可观察到 Scrapyd 服务,如图 15-2 所示: 图 15-2 Scrapyd 主页 这样我们就完成了 Scrapyd Docker 镜像的构建并成功运行了。 然后我们可以将此镜像上传到 Docker Hub,例如我的 Docker Hub 用户名为 germey,新建了一个名为 scrapyd 的项目,首先可以打一个标签:

1
docker tag scrapyd:latest germey/scrapyd:latest

这里请自行替换成你的项目名称。 然后 Push 即可:

1
docker push germey/scrapyd:latest

之后我们在其他主机运行此命令即可启动 Scrapyd 服务:

1
docker run -d -p 6800:6800 germey/scrapyd

执行命令后会发现 Scrapyd 就可以成功在其他服务器上运行了。

3. 结语

这样我们就利用 Docker 解决了 Python 环境的问题,在后一节我们再解决一个批量部署 Docker 的问题就可以解决批量部署问题了。

Python

15.2 Scrapyd-Client 的使用

这里有现成的工具来完成部署过程,它叫作 Scrapyd-Client。本节将简单介绍使用 Scrapyd-Client 部署 Scrapy 项目的方法。

1. 准备工作

请先确保 Scrapyd-Client 已经正确安装,安装方式可以参考第 1 章的内容。

2. Scrapyd-Client 的功能

Scrapyd-Client 为了方便 Scrapy 项目的部署,提供两个功能:

  • 将项目打包成 Egg 文件。
  • 将打包生成的 Egg 文件通过 addversion.json 接口部署到 Scrapyd 上。

也就是说,Scrapyd-Client 帮我们把部署全部实现了,我们不需要再去关心 Egg 文件是怎样生成的,也不需要再去读 Egg 文件并请求接口上传了,这一切的操作只需要执行一个命令即可一键部署。

3. Scrapyd-Client 部署

要部署 Scrapy 项目,我们首先需要修改一下项目的配置文件,例如我们之前写的 Scrapy 微博爬虫项目,在项目的第一层会有一个 scrapy.cfg 文件,它的内容如下:

1
2
3
4
5
6
[settings]
default = weibo.settings

[deploy]
#url = http://localhost:6800/
project = weibo

在这里我们需要配置一下 deploy 部分,例如我们要将项目部署到 120.27.34.25 的 Scrapyd 上,就需要修改为如下内容:

1
2
3
[deploy]
url = http://120.27.34.25:6800/
project = weibo

这样我们再在 scrapy.cfg 文件所在路径执行如下命令:

1
scrapyd-deploy

运行结果如下:

1
2
3
4
Packing version 1501682277
Deploying to project "weibo" in http://120.27.34.25:6800/addversion.json
Server response (200):
{"status": "ok", "spiders": 1, "node_name": "datacrawl-vm", "project": "weibo", "version": "1501682277"}

返回这样的结果就代表部署成功了。 我们也可以指定项目版本,如果不指定的话默认为当前时间戳,指定的话通过 version 参数传递即可,例如:

1
scrapyd-deploy --version 201707131455

值得注意的是在 Python3 的 Scrapyd 1.2.0 版本中我们不要指定版本号为带字母的字符串,需要为纯数字,否则可能会出现报错。 另外如果我们有多台主机,我们可以配置各台主机的别名,例如可以修改配置文件为:

1
2
3
4
5
6
7
[deploy:vm1]
url = http://120.27.34.24:6800/
project = weibo

[deploy:vm2]
url = http://139.217.26.30:6800/
project = weibo

有多台主机的话就在此统一配置,一台主机对应一组配置,在 deploy 后面加上主机的别名即可,这样如果我们想将项目部署到 IP 为 139.217.26.30 的 vm2 主机,我们只需要执行如下命令:

1
scrapyd-deploy vm2

这样我们就可以将项目部署到名称为 vm2 的主机上了。 如此一来,如果我们有多台主机,我们只需要在 scrapy.cfg 文件中配置好各台主机的 Scrapyd 地址,然后调用 scrapyd-deploy 命令加主机名称即可实现部署,非常方便。 如果 Scrapyd 设置了访问限制的话,我们可以在配置文件中加入用户名和密码的配置,同时端口修改一下,修改成 Nginx 代理端口,如在第一章我们使用的是 6801,那么这里就需要改成 6801,修改如下:

1
2
3
4
5
6
7
8
9
10
11
[deploy:vm1]
url = http://120.27.34.24:6801/
project = weibo
username = admin
password = admin

[deploy:vm2]
url = http://139.217.26.30:6801/
project = weibo
username = germey
password = germey

这样通过加入 username 和 password 字段我们就可以在部署时自动进行 Auth 验证,然后成功实现部署。

4. 结语

本节介绍了利用 Scrapyd-Client 来方便地将项目部署到 Scrapyd 的过程,有了它部署不再是麻烦事。

Python

15.1 Scrapyd 分布式部署

分布式爬虫完成并可以成功运行了,但是有个环节非常烦琐,那就是代码部署。 我们设想下面的几个场景。

  • 如果采用上传文件的方式部署代码,我们首先将代码压缩,然后采用 SFTP 或 FTP 的方式将文件上传到服务器,之后再连接服务器将文件解压,每个服务器都需要这样配置。
  • 如果采用 Git 同步的方式部署代码,我们可以先把代码 Push 到某个 Git 仓库里,然后再远程连接各台主机执行 Pull 操作,同步代码,每个服务器同样需要做一次操作。

如果代码突然有更新,那我们必须更新每个服务器,而且万一哪台主机的版本没控制好,这可能会影响整体的分布式爬取状况。 所以我们需要一个更方便的工具来部署 Scrapy 项目,如果可以省去一遍遍逐个登录服务器部署的操作,那将会方便很多。 本节我们就来看看提供分布式部署的工具 Scrapyd。

1. 了解 Scrapyd

Scrapyd 是一个运行 Scrapy 爬虫的服务程序,它提供一系列 HTTP 接口来帮助我们部署、启动、停止、删除爬虫程序。Scrapyd 支持版本管理,同时还可以管理多个爬虫任务,利用它我们可以非常方便地完成 Scrapy 爬虫项目的部署任务调度。

2. 准备工作

请确保本机或服务器已经正确安装好了 Scrapyd,安装和配置的方法可以参见第 1 章的内容。

3. 访问 Scrapyd

安装并运行了 Scrapyd 之后,我们就可以访问服务器的 6800 端口看到一个 WebUI 页面了,例如我的服务器地址为 120.27.34.25,在上面安装好了 Scrapyd 并成功运行,那么我就可以在本地的浏览器中打开:http://120.27.34.25:6800,就可以看到 Scrapyd 的首页,这里请自行替换成你的服务器地址查看即可,如图 15-1 所示: 图 15-1 Scrapyd 首页 如果可以成功访问到此页面,那么证明 Scrapyd 配置就没有问题了。

4. Scrapyd 的功能

Scrapyd 提供了一系列 HTTP 接口来实现各种操作,在这里我们可以将接口的功能梳理一下,以 Scrapyd 所在的 IP 为 120.27.34.25 为例:

daemonstatus.json

这个接口负责查看 Scrapyd 当前的服务和任务状态,我们可以用 curl 命令来请求这个接口,命令如下:

1
curl http://139.217.26.30:6800/daemonstatus.json

这样我们就会得到如下结果:

1
{"status": "ok", "finished": 90, "running": 9, "node_name": "datacrawl-vm", "pending": 0}

返回结果是 Json 字符串,status 是当前运行状态, finished 代表当前已经完成的 Scrapy 任务,running 代表正在运行的 Scrapy 任务,pending 代表等待被调度的 Scrapyd 任务,node_name 就是主机的名称。

addversion.json

这个接口主要是用来部署 Scrapy 项目用的,在部署的时候我们需要首先将项目打包成 Egg 文件,然后传入项目名称和部署版本。 我们可以用如下的方式实现项目部署:

1
curl http://120.27.34.25:6800/addversion.json -F project=wenbo -F version=first -F egg=@weibo.egg

在这里 -F 即代表添加一个参数,同时我们还需要将项目打包成 Egg 文件放到本地。 这样发出请求之后我们可以得到如下结果:

1
{"status": "ok", "spiders": 3}

这个结果表明部署成功,并且其中包含的 Spider 的数量为 3。 此方法部署可能比较繁琐,在后文会介绍更方便的工具来实现项目的部署。

schedule.json

这个接口负责调度已部署好的 Scrapy 项目运行。 我们可以用如下接口实现任务调度:

1
curl http://120.27.34.25:6800/schedule.json -d project=weibo -d spider=weibocn

在这里需要传入两个参数,project 即 Scrapy 项目名称,spider 即 Spider 名称。 返回结果如下:

1
{"status": "ok", "jobid": "6487ec79947edab326d6db28a2d86511e8247444"}

status 代表 Scrapy 项目启动情况,jobid 代表当前正在运行的爬取任务代号。

cancel.json

这个接口可以用来取消某个爬取任务,如果这个任务是 pending 状态,那么它将会被移除,如果这个任务是 running 状态,那么它将会被终止。 我们可以用下面的命令来取消任务的运行:

1
curl http://120.27.34.25:6800/cancel.json -d project=weibo -d job=6487ec79947edab326d6db28a2d86511e8247444

在这里需要传入两个参数,project 即项目名称,job 即爬取任务代号。 返回结果如下:

1
{"status": "ok", "prevstate": "running"}

status 代表请求执行情况,prevstate 代表之前的运行状态。

listprojects.json

这个接口用来列出部署到 Scrapyd 服务上的所有项目描述。 我们可以用下面的命令来获取 Scrapyd 服务器上的所有项目描述:

1
curl http://120.27.34.25:6800/listprojects.json

这里不需要传入任何参数。 返回结果如下:

1
{"status": "ok", "projects": ["weibo", "zhihu"]}

status 代表请求执行情况,projects 是项目名称列表。

listversions.json

这个接口用来获取某个项目的所有版本号,版本号是按序排列的,最后一个条目是最新的版本号。 我们可以用如下命令来获取项目的版本号:

1
curl http://120.27.34.25:6800/listversions.json?project=weibo

在这里需要一个参数 project,就是项目的名称。 返回结果如下:

1
{"status": "ok", "versions": ["v1", "v2"]}

status 代表请求执行情况,versions 是版本号列表。

listspiders.json

这个接口用来获取某个项目最新的一个版本的所有 Spider 名称。 我们可以用如下命令来获取项目的 Spider 名称:

1
curl http://120.27.34.25:6800/listspiders.json?project=weibo

在这里需要一个参数 project,就是项目的名称。 返回结果如下:

1
{"status": "ok", "spiders": ["weibocn"]}

status 代表请求执行情况,spiders 是 Spider 名称列表。

listjobs.json

这个接口用来获取某个项目当前运行的所有任务详情。 我们可以用如下命令来获取所有任务详情:

1
curl http://120.27.34.25:6800/listjobs.json?project=weibo

在这里需要一个参数 project,就是项目的名称。 返回结果如下:

1
2
3
4
{"status": "ok",
"pending": [{"id": "78391cc0fcaf11e1b0090800272a6d06", "spider": "weibocn"}],
"running": [{"id": "422e608f9f28cef127b3d5ef93fe9399", "spider": "weibocn", "start_time": "2017-07-12 10:14:03.594664"}],
"finished": [{"id": "2f16646cfcaf11e1b0090800272a6d06", "spider": "weibocn", "start_time": "2017-07-12 10:14:03.594664", "end_time": "2017-07-12 10:24:03.594664"}]}

status 代表请求执行情况,pendings 代表当前正在等待的任务,running 代表当前正在运行的任务,finished 代表已经完成的任务。

delversion.json

这个接口用来删除项目的某个版本。 我们可以用如下命令来删除项目版本:

1
curl http://120.27.34.25:6800/delversion.json -d project=weibo -d version=v1

在这里需要一个参数 project,就是项目的名称,还需要一个参数 version,就是项目的版本。 返回结果如下:

1
{"status": "ok"}

status 代表请求执行情况,这样就代表删除成功了。

delproject.json

这个接口用来删除某个项目。 我们可以用如下命令来删除某个项目:

1
curl http://120.27.34.25:6800/delproject.json -d project=weibo

在这里需要一个参数 project,就是项目的名称。 返回结果如下:

1
{"status": "ok"}

status 代表请求执行情况,这样就代表删除成功了。 以上就是 Scrapyd 所有的接口,我们可以直接请求 HTTP 接口即可控制项目的部署、启动、运行等操作。

5. ScrapydAPI 的使用

以上的这些接口可能使用起来还不是很方便,没关系,还有一个 ScrapydAPI 库对这些接口又做了一层封装,其安装方式也可以参考第一章的内容。 下面我们来看下 ScrapydAPI 的使用方法,其实核心原理和 HTTP 接口请求方式并无二致,只不过用 Python 封装后使用更加便捷。 我们可以用如下方式建立一个 ScrapydAPI 对象:

1
2
from scrapyd_api import ScrapydAPI
scrapyd = ScrapydAPI('http://120.27.34.25:6800')

然后就可以调用它的方法来实现对应接口的操作了,例如部署的操作可以使用如下方式:

1
2
egg = open('weibo.egg', 'rb')
scrapyd.add_version('weibo', 'v1', egg)

这样我们就可以将项目打包为 Egg 文件,然后把本地打包的的 Egg 项目部署到远程 Scrapyd 了。 另外 ScrapydAPI 还实现了所有 Scrapyd 提供的 API 接口,名称都是相同的,参数也是相同的。 例如我们可以调用 list_projects() 方法即可列出 Scrapyd 中所有已部署的项目:

1
2
scrapyd.list_projects()
['weibo', 'zhihu']

另外还有其他的方法在此不再一一列举了,名称和参数都是相同的,更加详细的操作可以参考其官方文档:http://python-scrapyd-api.readthedocs.io/

6. 结语

本节介绍了 Scrapyd 及 ScrapydAPI 的相关用法,我们可以通过它来部署项目,并通过 HTTP 接口来控制人物的运行,不过这里有一个不方便的地方就是部署过程,首先它需要打包 Egg 文件然后再上传,还是比较繁琐的,在下一节我们介绍一个更加方便的工具来完成部署过程。

Python

14.4 Bloom Filter 的对接

首先回顾一下 Scrapy-Redis 的去重机制。Scrapy-Redis 将 Request 的指纹存储到了 Redis 集合中,每个指纹的长度为 40,例如 27adcc2e8979cdee0c9cecbbe8bf8ff51edefb61 就是一个指纹,它的每一位都是 16 进制数。 我们计算一下用这种方式耗费的存储空间。每个十六进制数占用 4 b,1 个指纹用 40 个十六进制数表示,占用空间为 20 B,1 万个指纹即占用空间 200 KB,1 亿个指纹占用 2 GB。当爬取数量达到上亿级别时,Redis 的占用的内存就会变得很大,而且这仅仅是指纹的存储。Redis 还存储了爬取队列,内存占用会进一步提高,更别说有多个 Scrapy 项目同时爬取的情况了。当爬取达到亿级别规模时,Scrapy-Redis 提供的集合去重已经不能满足我们的要求。所以我们需要使用一个更加节省内存的去重算法 Bloom Filter。

1. 了解 BloomFilter

Bloom Filter,中文名称叫作布隆过滤器,是 1970 年由 Bloom 提出的,它可以被用来检测一个元素是否在一个集合中。Bloom Filter 的空间利用效率很高,使用它可以大大节省存储空间。Bloom Filter 使用位数组表示一个待检测集合,并可以快速地通过概率算法判断一个元素是否存在于这个集合中。利用这个算法我们可以实现去重效果。 本节我们来了解 Bloom Filter 的基本算法,以及 Scrapy-Redis 中对接 Bloom Filter 的方法。

2. BloomFilter 的算法

在 Bloom Filter 中使用位数组来辅助实现检测判断。在初始状态下,我们声明一个包含 m 位的位数组,它的所有位都是 0,如图 14-7 所示。 图 14-7 初始位数组 现在我们有了一个待检测集合,我们表示为 S={x1, x2, …, xn},我们接下来需要做的就是检测一个 x 是否已经存在于集合 S 中。在 BloomFilter 算法中首先使用 k 个相互独立的、随机的哈希函数来将这个集合 S 中的每个元素 x1、x2、…、xn 映射到这个长度为 m 的位数组上,哈希函数得到的结果记作位置索引,然后将位数组该位置索引的位置 1。例如这里我们取 k 为 3,即有三个哈希函数,x1 经过三个哈希函数映射得到的结果分别为 1、4、8,x2 经过三个哈希函数映射得到的结果分别为 4、6、10,那么就会将位数组的 1、4、6、8、10 这五位置 1,如图 14-8 所示: 图 14-8 映射后位数组 这时如果再有一个新的元素 x,我们要判断 x 是否属于 S 这个集合,我们便会将仍然用 k 个哈希函数对 x 求映射结果,如果所有结果对应的位数组位置均为 1,那么我们就认为 x 属于 S 这个集合,否则如果有一个不为 1,则 x 不属于 S 集合。 例如一个新元素 x 经过三个哈希函数映射的结果为 4、6、8,对应的位置均为 1,则判断 x 属于 S 这个集合。如果结果为 4、6、7,7 对应的位置为 0,则判定 x 不属于 S 这个集合。 注意这里 m、n、k 满足的关系是 m>nk,也就是说位数组的长度 m 要比集合元素 n 和哈希函数 k 的乘积还要大。 这样的判定方法很高效,但是也是有代价的,它可能把不属于这个集合的元素误认为属于这个集合,我们来估计一下它的错误率。当集合 S={x1, x2,…, xn} 的所有元素都被 k 个哈希函数映射到 m 位的位数组中时,这个位数组中某一位还是 0 的概率是: 因为哈希函数是随机的,所以任意一个哈希函数选中这一位的概率为 1/m,那么 1-1/m 就代表哈希函数一次没有选中这一位的概率,要把 S 完全映射到 m 位数组中,需要做 kn 次哈希运算,所以最后的概率就是 1-1/m 的 kn 次方。 一个不属于 S 的元素 x 如果要被误判定为在 S 中,那么这个概率就是 k 次哈希运算得到的结果对应的位数组位置都为 1,所以误判概率为: 根据: 可以将误判概率转化为: 在给定 m、n 时,可以求出使得 f 最小化的 k 值为: 在这里将误判概率归纳如下: 表 14-1 误判概率

m/n

最优 k

k=1

k=2

k=3

k=4

k=5

k=6

k=7

k=8

2

1.39

0.393

0.400

3

2.08

0.283

0.237

0.253

4

2.77

0.221

0.155

0.147

0.160

5

3.46

0.181

0.109

0.092

0.092

0.101

6

4.16

0.154

0.0804

0.0609

0.0561

0.0578

0.0638

7

4.85

0.133

0.0618

0.0423

0.0359

0.0347

0.0364

8

5.55

0.118

0.0489

0.0306

0.024

0.0217

0.0216

0.0229

9

6.24

0.105

0.0397

0.0228

0.0166

0.0141

0.0133

0.0135

0.0145

10

6.93

0.0952

0.0329

0.0174

0.0118

0.00943

0.00844

0.00819

0.00846

11

7.62

0.0869

0.0276

0.0136

0.00864

0.0065

0.00552

0.00513

0.00509

12

8.32

0.08

0.0236

0.0108

0.00646

0.00459

0.00371

0.00329

0.00314

13

9.01

0.074

0.0203

0.00875

0.00492

0.00332

0.00255

0.00217

0.00199

14

9.7

0.0689

0.0177

0.00718

0.00381

0.00244

0.00179

0.00146

0.00129

15

10.4

0.0645

0.0156

0.00596

0.003

0.00183

0.00128

0.001

0.000852

16

11.1

0.0606

0.0138

0.005

0.00239

0.00139

0.000935

0.000702

0.000574

17

11.8

0.0571

0.0123

0.00423

0.00193

0.00107

0.000692

0.000499

0.000394

18

12.5

0.054

0.0111

0.00362

0.00158

0.000839

0.000519

0.00036

0.000275

19

13.2

0.0513

0.00998

0.00312

0.0013

0.000663

0.000394

0.000264

0.000194

20

13.9

0.0488

0.00906

0.0027

0.00108

0.00053

0.000303

0.000196

0.00014

21

14.6

0.0465

0.00825

0.00236

0.000905

0.000427

0.000236

0.000147

0.000101

22

15.2

0.0444

0.00755

0.00207

0.000764

0.000347

0.000185

0.000112

7.46e-05

23

15.9

0.0425

0.00694

0.00183

0.000649

0.000285

0.000147

8.56e-05

5.55e-05

24

16.6

0.0408

0.00639

0.00162

0.000555

0.000235

0.000117

6.63e-05

4.17e-05

25

17.3

0.0392

0.00591

0.00145

0.000478

0.000196

9.44e-05

5.18e-05

3.16e-05

26

18

0.0377

0.00548

0.00129

0.000413

0.000164

7.66e-05

4.08e-05

2.42e-05

27

18.7

0.0364

0.0051

0.00116

0.000359

0.000138

6.26e-05

3.24e-05

1.87e-05

28

19.4

0.0351

0.00475

0.00105

0.000314

0.000117

5.15e-05

2.59e-05

1.46e-05

29

20.1

0.0339

0.00444

0.000949

0.000276

9.96e-05

4.26e-05

2.09e-05

1.14e-05

30

20.8

0.0328

0.00416

0.000862

0.000243

8.53e-05

3.55e-05

1.69e-05

9.01e-06

31

21.5

0.0317

0.0039

0.000785

0.000215

7.33e-05

2.97e-05

1.38e-05

7.16e-06

32

22.2

0.0308

0.00367

0.000717

0.000191

6.33e-05

2.5e-05

1.13e-05

5.73e-06

表 14-1 中第一列为 m/n 的值,第二列为最优 k 值,其后列为不同 k 值的误判概率,可以看到当 k 值确定时,随着 m/n 的增大,误判概率逐渐变小。当 m/n 的值确定时,当 k 越靠近最优 K 值,误判概率越小。另外误判概率总体来看都是极小的,在容忍此误判概率的情况下,大幅减小存储空间和判定速度是完全值得的。 接下来我们就将 BloomFilter 算法应用到 Scrapy-Redis 分布式爬虫的去重过程中,以解决 Redis 内存不足的问题。

3. 对接 Scrapy-Redis

实现 BloomFilter 时,我们首先要保证不能破坏 Scrapy-Redis 分布式爬取的运行架构,所以我们需要修改 Scrapy-Redis 的源码,将它的去重类替换掉。同时 BloomFilter 的实现需要借助于一个位数组,所以既然当前架构还是依赖于 Redis 的,那么正好位数组的维护直接使用 Redis 就好了。 首先我们实现一个基本的哈希算法,可以实现将一个值经过哈希运算后映射到一个 m 位位数组的某一位上,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class HashMap(object):
def __init__(self, m, seed):
self.m = m
self.seed = seed

def hash(self, value):
"""
Hash Algorithm
:param value: Value
:return: Hash Value
"""
ret = 0
for i in range(len(value)):
ret += self.seed * ret + ord(value[i])
return (self.m - 1) & ret

在这里新建了一个 HashMap 类,构造函数传入两个值,一个是 m 位数组的位数,另一个是种子值 seed,不同的哈希函数需要有不同的 seed,这样可以保证不同的哈希函数的结果不会碰撞。 在 hash() 方法的实现中,value 是要被处理的内容,在这里我们遍历了该字符的每一位并利用 ord() 方法取到了它的 ASCII 码值,然后混淆 seed 进行迭代求和运算,最终会得到一个数值。这个数值的结果就由 value 和 seed 唯一确定,然后我们再将它和 m 进行按位与运算,即可获取到 m 位数组的映射结果,这样我们就实现了一个由字符串和 seed 来确定的哈希函数。当 m 固定时,只要 seed 值相同,就代表是同一个哈希函数,相同的 value 必然会映射到相同的位置。所以如果我们想要构造几个不同的哈希函数,只需要改变其 seed 就好了,以上便是一个简易的哈希函数的实现。 接下来我们再实现 BloomFilter,BloomFilter 里面需要用到 k 个哈希函数,所以在这里我们需要对这几个哈希函数指定相同的 m 值和不同的 seed 值,在这里构造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
BLOOMFILTER_HASH_NUMBER = 6
BLOOMFILTER_BIT = 30

class BloomFilter(object):
def __init__(self, server, key, bit=BLOOMFILTER_BIT, hash_number=BLOOMFILTER_HASH_NUMBER):
"""
Initialize BloomFilter
:param server: Redis Server
:param key: BloomFilter Key
:param bit: m = 2 ^ bit
:param hash_number: the number of hash function
"""
# default to 1 << 30 = 10,7374,1824 = 2^30 = 128MB, max filter 2^30/hash_number = 1,7895,6970 fingerprints
self.m = 1 << bit
self.seeds = range(hash_number)
self.maps = [HashMap(self.m, seed) for seed in self.seeds]
self.server = server
self.key = key

由于我们需要亿级别的数据的去重,即前文介绍的算法中的 n 为 1 亿以上,哈希函数的个数 k 大约取 10 左右的量级,而 m>kn,所以这里 m 值大约保底在 10 亿,由于这个数值比较大,所以这里用移位操作来实现,传入位数 bit,定义 30,然后做一个移位操作 1 << 30,相当于 2 的 30 次方,等于 1073741824,量级也是恰好在 10 亿左右,由于是位数组,所以这个位数组占用的大小就是 2^30b=128MB,而本文开头我们计算过 Scrapy-Redis 集合去重的占用空间大约在 2G 左右,可见 BloomFilter 的空间利用效率之高。 随后我们再传入哈希函数的个数,用它来生成几个不同的 seed,用不同的 seed 来定义不同的哈希函数,这样我们就可以构造一个哈希函数列表,遍历 seed,构造带有不同 seed 值的 HashMap 对象,保存成变量 maps 供后续使用。 另外 server 就是 Redis 连接对象,key 就是这个 m 位数组的名称。 接下来我们就要实现比较关键的两个方法了,一个是判定元素是否重复的方法 exists(),另一个是添加元素到集合中的方法 insert(),实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def exists(self, value):
"""
if value exists
:param value:
:return:
"""
if not value:
return False
exist = 1
for map in self.maps:
offset = map.hash(value)
exist = exist & self.server.getbit(self.key, offset)
return exist

def insert(self, value):
"""
add value to bloom
:param value:
:return:
"""
for f in self.maps:
offset = f.hash(value)
self.server.setbit(self.key, offset, 1)

首先我们先看下 insert() 方法,BloomFilter 算法中会逐个调用哈希函数对放入集合中的元素进行运算得到在 m 位位数组中的映射位置,然后将位数组对应的位置置 1,所以这里在代码中我们遍历了初始化好的哈希函数,然后调用其 hash() 方法算出映射位置 offset,再利用 Redis 的 setbit() 方法将该位置 1。 在 exists() 方法中我们就需要实现判定是否重复的逻辑了,方法参数 value 即为待判断的元素,在这里我们首先定义了一个变量 exist,然后遍历了所有哈希函数对 value 进行哈希运算,得到映射位置,然后我们用 getbit() 方法取得该映射位置的结果,依次进行与运算。这样只有每次 getbit() 得到的结果都为 1 时,最后的 exist 才为 True,即代表 value 属于这个集合。如果其中只要有一次 getbit() 得到的结果为 0,即 m 位数组中有对应的 0 位,那么最终的结果 exist 就为 False,即代表 value 不属于这个集合。这样此方法最后的返回结果就是判定重复与否的结果了。 到现在为止 BloomFilter 的实现就已经完成了,我们可以用一个实例来测试一下,代码如下:

1
2
3
4
5
6
7
8
conn = StrictRedis(host='localhost', port=6379, password='foobared')
bf = BloomFilter(conn, 'testbf', 5, 6)
bf.insert('Hello')
bf.insert('World')
result = bf.exists('Hello')
print(bool(result))
result = bf.exists('Python')
print(bool(result))

在这里我们首先定义了一个 Redis 连接对象,然后传递给 BloomFilter,为了避免内存占用过大这里传的位数 bit 比较小,设置为 5,哈希函数的个数设置为 6。 首先我们调用 insert() 方法插入了 Hello 和 World 两个字符串,随后判断了一下 Hello 和 Python 这两个字符串是否存在,最后输出它的结果,运行结果如下:

1
2
True
False

很明显,结果完全没有问题,这样我们就借助于 Redis 成功实现了 BloomFilter 的算法。 接下来我们需要继续修改 Scrapy-Redis 的源码,将它的 dupefilter 逻辑替换为 BloomFilter 的逻辑,在这里主要是修改 RFPDupeFilter 类的 request_seen() 方法,实现如下:

1
2
3
4
5
6
def request_seen(self, request):
fp = self.request_fingerprint(request)
if self.bf.exists(fp):
return True
self.bf.insert(fp)
return False

首先还是利用 request_fingerprint() 方法获取了 Request 的指纹,然后调用 BloomFilter 的 exists() 方法判定了该指纹是否存在,如果存在,则证明该 Request 是重复的,返回 True,否则调用 BloomFilter 的 insert() 方法将该指纹添加并返回 False,这样就成功利用 BloomFilter 替换了 Scrapy-Redis 的集合去重。 对于 BloomFilter 的初始化定义,我们可以将 init() 方法修改为如下内容:

1
2
3
4
5
6
7
8
def __init__(self, server, key, debug, bit, hash_number):
self.server = server
self.key = key
self.debug = debug
self.bit = bit
self.hash_number = hash_number
self.logdupes = True
self.bf = BloomFilter(server, self.key, bit, hash_number)

其中 bit 和 hash_number 需要使用 from_settings() 方法传递,修改如下:

1
2
3
4
5
6
7
8
@classmethod
def from_settings(cls, settings):
server = get_redis_from_settings(settings)
key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())}
debug = settings.getbool('DUPEFILTER_DEBUG', DUPEFILTER_DEBUG)
bit = settings.getint('BLOOMFILTER_BIT', BLOOMFILTER_BIT)
hash_number = settings.getint('BLOOMFILTER_HASH_NUMBER', BLOOMFILTER_HASH_NUMBER)
return cls(server, key=key, debug=debug, bit=bit, hash_number=hash_number)

其中常量的定义 DUPEFILTER_DEBUG 和 BLOOMFILTER_BIT 统一定义在 defaults.py 中,默认如下:

1
2
BLOOMFILTER_HASH_NUMBER = 6
BLOOMFILTER_BIT = 30

到此为止我们就成功实现了 BloomFilter 和 Scrapy-Redis 的对接。

4. 本节代码

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

5. 使用

为了方便使用,本节的代码已经打包成了一个 Python 包并发布到了 PyPi,链接为:https://pypi.python.org/pypi/scrapy-redis-bloomfilter,因此我们以后如果想使用 ScrapyRedisBloomFilter 直接使用就好了,不需要再自己实现一遍。 我们可以直接使用 Pip 来安装,命令如下:

1
pip3 install scrapy-redis-bloomfilter

使用的方法和 Scrapy-Redis 基本相似,在这里说明几个关键配置:

1
2
3
4
5
6
# 去重类,要使用 BloomFilter 请替换 DUPEFILTER_CLASS
DUPEFILTER_CLASS = "scrapy_redis_bloomfilter.dupefilter.RFPDupeFilter"
# 哈希函数的个数,默认为 6,可以自行修改
BLOOMFILTER_HASH_NUMBER = 6
# BloomFilter 的 bit 参数,默认 30,占用 128MB 空间,去重量级 1 亿
BLOOMFILTER_BIT = 30

DUPEFILTER_CLASS 是去重类,如果要使用 BloomFilter 需要将 DUPEFILTER_CLASS 修改为该包的去重类。 BLOOMFILTER_HASH_NUMBER 是 BloomFilter 使用的哈希函数的个数,默认为 6,可以根据去重量级自行修改。 BLOOMFILTER_BIT 即前文所介绍的 BloomFilter 类的 bit 参数,它决定了位数组的位数,如果 BLOOMFILTER_BIT 为 30,那么位数组位数为 2 的 30 次方,将占用 Redis 128MB 的存储空间,去重量级在 1 亿左右,即对应爬取量级 1 亿左右。如果爬取量级在 10 亿、20 亿甚至 100 亿,请务必将此参数对应调高。

6. 测试

在源代码中附有一个测试项目,放在 tests 文件夹,该项目使用了 Scrapy-RedisBloomFilter 来去重,Spider 的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from scrapy import Request, Spider

class TestSpider(Spider):
name = 'test'
base_url = 'https://www.baidu.com/s?wd='

def start_requests(self):
for i in range(10):
url = self.base_url + str(i)
yield Request(url, callback=self.parse)

# Here contains 10 duplicated Requests
for i in range(100):
url = self.base_url + str(i)
yield Request(url, callback=self.parse)

def parse(self, response):
self.logger.debug('Response of ' + response.url)

在 start_requests() 方法中首先循环 10 次,构造参数为 0-9 的 URL,然后重新循环了 100 次,构造了参数为 0-99 的 URL,那么这里就会包含 10 个重复的 Request,我们运行项目测试一下:

1
scrapy crawl test

可以看到最后的输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{'bloomfilter/filtered': 10,
'downloader/request_bytes': 34021,
'downloader/request_count': 100,
'downloader/request_method_count/GET': 100,
'downloader/response_bytes': 72943,
'downloader/response_count': 100,
'downloader/response_status_count/200': 100,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2017, 8, 11, 9, 34, 30, 419597),
'log_count/DEBUG': 202,
'log_count/INFO': 7,
'memusage/max': 54153216,
'memusage/startup': 54153216,
'response_received_count': 100,
'scheduler/dequeued/redis': 100,
'scheduler/enqueued/redis': 100,
'start_time': datetime.datetime(2017, 8, 11, 9, 34, 26, 495018)}

可以看到最后统计的第一行的结果:

1
'bloomfilter/filtered': 10,

这就是 BloomFilter 过滤后的统计结果,可以看到它的过滤个数为 10 个,也就是它成功将重复的 10 个 Reqeust 识别出来了,测试通过。

7. 结语

以上便是 BloomFilter 的原理及对接实现,使用了 BloomFilter 可以大大节省 Redis 内存,在数据量大的情况下推荐使用此方案。

Python

14.3 Scrapy 分布式实现

接下来,我们会利用 Scrapy-Redis 来实现分布式的对接。

1. 准备工作

请确保已经成功实现了 Scrapy 新浪微博爬虫,Scrapy-Redis 库已经正确安装,如果还没安装,请参考第 1 章的安装说明。

2. 搭建 Redis 服务器

要实现分布式部署,多台主机需要共享爬取队列和去重集合,而这两部分内容都是存于 Redis 数据库中的,我们需要搭建一个可公网访问的 Redis 服务器。 推荐使用 Linux 服务器,可以购买阿里云、腾讯云、Azure 等提供的云主机,一般都会配有公网 IP,具体的搭建方式可以参考第 1 章中 Redis 数据库的安装方式。 Redis 安装完成之后就可以远程连接了,注意部分商家(如阿里云、腾讯云)的服务器需要配置安全组放通 Redis 运行端口才可以远程访问。如果遇到不能远程连接的问题,可以排查安全组的设置。 需要记录 Redis 的运行 IP、端口、地址,供后面配置分布式爬虫使用。当前配置好的 Redis 的 IP 为服务器的 IP 120.27.34.25,端口为默认的 6379,密码为 foobared。

3. 部署代理池和 Cookies 池

新浪微博项目需要用到代理池和 Cookies 池,而之前我们的代理池和 Cookies 池都是在本地运行的。所以我们需要将二者放到可以被公网访问的服务器上运行,将代码上传到服务器,修改 Redis 的连接信息配置,用同样的方式运行代理池和 Cookies 池。 远程访问代理池和 Cookies 池提供的接口,来获取随机代理和 Cookies。如果不能远程访问,先确保其在 0.0.0.0 这个 Host 上运行,再检查安全组的配置。 如我当前配置好的代理池和 Cookies 池的运行 IP 都是服务器的 IP,120.27.34.25,端口分别为 5555 和 5556,如图 14-3 和图 14-4 所示。 图 14-3 代理池接口 图 14-4 Cookies 池接口 所以接下来我们就需要把 Scrapy 新浪微博项目中的访问链接修改如下:

1
2
PROXY_URL = 'http://120.27.34.25:5555/random'
COOKIES_URL = 'http://120.27.34.25:5556/weibo/random'

具体的修改方式根据实际配置的 IP 和端口做相应调整。

4. 配置 Scrapy-Redis

配置 Scrapy-Redis 非常简单,只需要修改一下 settings.py 配置文件即可。

核心配置

首先最主要的是,需要将调度器的类和去重的类替换为 Scrapy-Redis 提供的类,在 settings.py 里面添加如下配置即可:

1
2
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"

Redis 连接配置

接下来配置 Redis 的连接信息,这里有两种配置方式。 第一种方式是通过连接字符串配置。我们可以用 Redis 的地址、端口、密码来构造一个 Redis 连接字符串,支持的连接形式如下所示:

1
2
3
redis://[:password]@host:port/db
rediss://[:password]@host:port/db
unix://[:password]@/path/to/socket.sock?db=db

password 是密码,比如要以冒号开头,中括号代表此选项可有可无,host 是 Redis 的地址,port 是运行端口,db 是数据库代号,其值默认是 0。 根据上文中提到我的 Redis 连接信息,构造这个 Redis 的连接字符串如下所示:

1
redis://:foobared@120.27.34.25:6379

直接在 settings.py 里面配置为 REDIS_URL 变量即可:

1
REDIS_URL = 'redis://:foobared@120.27.34.25:6379'

第二种配置方式是分项单独配置。这个配置就更加直观明了,如根据我的 Redis 连接信息,可以在 settings.py 中配置如下代码:

1
2
3
REDIS_HOST = '120.27.34.25'
REDIS_PORT = 6379
REDIS_PASSWORD = 'foobared'

这段代码分开配置了 Redis 的地址、端口和密码。 注意,如果配置了 REDIS_URL,那么 Scrapy-Redis 将优先使用 REDIS_URL 连接,会覆盖上面的三项配置。如果想要分项单独配置的话,请不要配置 REDIS_URL。 在本项目中,我选择的是配置 REDIS_URL。

配置调度队列

此项配置是可选的,默认使用 PriorityQueue。如果想要更改配置,可以配置 SCHEDULER_QUEUE_CLASS 变量,如下所示:

1
2
3
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.PriorityQueue'
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.FifoQueue'
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.LifoQueue'

以上三行任选其一配置,即可切换爬取队列的存储方式。 在本项目中不进行任何配置,我们使用默认配置。

配置持久化

此配置是可选的,默认是 False。Scrapy-Redis 默认会在爬取全部完成后清空爬取队列和去重指纹集合。 如果不想自动清空爬取队列和去重指纹集合,可以增加如下配置:

1
SCHEDULER_PERSIST = True

将 SCHEDULER_PERSIST 设置为 True 之后,爬取队列和去重指纹集合不会在爬取完成后自动清空,如果不配置,默认是 False,即自动清空。 值得注意的是,如果强制中断爬虫的运行,爬取队列和去重指纹集合是不会自动清空的。 在本项目中不进行任何配置,我们使用默认配置。

配置重爬

此配置是可选的,默认是 False。如果配置了持久化或者强制中断了爬虫,那么爬取队列和指纹集合不会被清空,爬虫重新启动之后就会接着上次爬取。如果想重新爬取,我们可以配置重爬的选项:

1
SCHEDULER_FLUSH_ON_START = True

这样将 SCHEDULER_FLUSH_ON_START 设置为 True 之后,爬虫每次启动时,爬取队列和指纹集合都会清空。所以要做分布式爬取,我们必须保证只能清空一次,否则每个爬虫任务在启动时都清空一次,就会把之前的爬取队列清空,势必会影响分布式爬取。 注意,此配置在单机爬取的时候比较方便,分布式爬取不常用此配置。 在本项目中不进行任何配置,我们使用默认配置。

Pipeline 配置

此配置是可选的,默认不启动 Pipeline。Scrapy-Redis 实现了一个存储到 Redis 的 Item Pipeline,启用了这个 Pipeline 的话,爬虫会把生成的 Item 存储到 Redis 数据库中。在数据量比较大的情况下,我们一般不会这么做。因为 Redis 是基于内存的,我们利用的是它处理速度快的特性,用它来做存储未免太浪费了,配置如下:

1
ITEM_PIPELINES = {'scrapy_redis.pipelines.RedisPipeline': 300}

本项目不进行任何配置,即不启动 Pipeline。 到此为止,Scrapy-Redis 的配置就完成了。有的选项我们没有配置,但是这些配置在其他 Scrapy 项目中可能用到,要根据具体情况而定。

5. 配置存储目标

之前 Scrapy 新浪微博爬虫项目使用的存储是 MongoDB,而且 MongoDB 是本地运行的,即连接的是 localhost。但是,当爬虫程序分发到各台主机运行的时候,爬虫就会连接各自的的 MongoDB。所以我们需要在各台主机上都安装 MongoDB,这样有两个缺点:一是搭建 MongoDB 环境比较烦琐;二是这样各台主机的爬虫会把爬取结果分散存到各自主机上,不方便统一管理。 所以我们最好将存储目标存到同一个地方,例如都存到同一个 MongoDB 数据库中。我们可以在服务器上搭建一个 MongoDB 服务,或者直接购买 MongoDB 数据存储服务。 这里使用的就是服务器上搭建的的 MongoDB 服务,IP 仍然为 120.27.34.25,用户名为 admin,密码为 admin123。 修改配置 MONGO_URI 为如下:

1
MONGO_URI = 'mongodb://admin:admin123@120.27.34.25:27017'

到此为止,我们就成功完成了 Scrapy 分布式爬虫的配置了。

6. 运行

接下来将代码部署到各台主机上,记得每台主机都需要配好对应的 Python 环境。 每台主机上都执行如下命令,即可启动爬取:

1
scrapy crawl weibocn

每台主机启动了此命令之后,就会从配置的 Redis 数据库中调度 Request,做到爬取队列共享和指纹集合共享。同时每台主机占用各自的带宽和处理器,不会互相影响,爬取效率成倍提高。

7. 结果

一段时间后,我们可以用 RedisDesktop 观察远程 Redis 数据库的信息。这里会出现两个 Key:一个叫作 weibocn:dupefilter,用来储存指纹;另一个叫作 weibocn:requests,即爬取队列,如图 14-5 和图 14-6 所示。 图 14-5 去重指纹 图 14-6 爬取队列 随着时间的推移,指纹集合会不断增长,爬取队列会动态变化,爬取的数据也会被储存到 MongoDB 数据库中。 至此 Scrapy 分布式的配置已全部完成。

8. 本节代码

本节代码地址为:https://github.com/Python3WebSpider/Weibo/tree/distributed,注意这里是 distributed 分支。

9. 结语

本节通过对接 Scrapy-Redis 成功实现了分布式爬虫,但是部署还是有很多不方便的地方。另外,如果爬取量特别大的话,Redis 的内存也是个问题。在后文我们会继续了解相关优化方案。

Python

14.2 Scrapy-Redis 源码解析

Scrapy-Redis 库已经为我们提供了 Scrapy 分布式的队列、调度器、去重等功能,其 GitHub 地址为:https://github.com/rmax/scrapy-redis。 本节我们深入了解一下,利用 Redis 如何实现 Scrapy 分布式。

1. 获取源码

可以把源码克隆下来,执行如下命令:

1
git clone https://github.com/rmax/scrapy-redis.git

核心源码在 scrapy-redis/src/scrapy_redis 目录下。

2. 爬取队列

从爬取队列入手,看看它的具体实现。源码文件为 queue.py,它有三个队列的实现,首先它实现了一个父类 Base,提供一些基本方法和属性,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Base(object):
"""Per-spider base queue class"""
def __init__(self, server, spider, key, serializer=None):
if serializer is None:
serializer = picklecompat
if not hasattr(serializer, 'loads'):
raise TypeError("serializer does not implement 'loads' function: % r"
% serializer)
if not hasattr(serializer, 'dumps'):
raise TypeError("serializer '% s' does not implement 'dumps' function: % r"
% serializer)
self.server = server
self.spider = spider
self.key = key % {'spider': spider.name}
self.serializer = serializer

def _encode_request(self, request):
obj = request_to_dict(request, self.spider)
return self.serializer.dumps(obj)

def _decode_request(self, encoded_request):
obj = self.serializer.loads(encoded_request)
return request_from_dict(obj, self.spider)

def __len__(self):
"""Return the length of the queue"""
raise NotImplementedError

def push(self, request):
"""Push a request"""
raise NotImplementedError

def pop(self, timeout=0):
"""Pop a request"""
raise NotImplementedError

def clear(self):
"""Clear queue/stack"""
self.server.delete(self.key)

首先看一下 encoderequest() 和 _decode_request() 方法,因为我们需要把一 个 Request 对象存储到数据库中,但数据库无法直接存储对象,所以需要将 Request 序列化转成字符串再存储,而这两个方法就分别是序列化和反序列化的操作,利用 pickle 库来实现,一般在调用 push() 将 Request 存入数据库时会调用 _encode_request() 方法进行序列化,在调用 pop() 取出 Request 的时候会调用 _decode_request() 进行反序列化。 在父类中 __len()、push() 和 pop() 方法都是未实现的,会直接抛出 NotImplementedError,因此这个类是不能直接被使用的,所以必须要实现一个子类来重写这三个方法,而不同的子类就会有不同的实现,也就有着不同的功能。 那么接下来就需要定义一些子类来继承 Base 类,并重写这几个方法,那在源码中就有三个子类的实现,它们分别是 FifoQueue、PriorityQueue、LifoQueue,我们分别来看下它们的实现原理。 首先是 FifoQueue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class FifoQueue(Base):
"""Per-spider FIFO queue"""

def __len__(self):
"""Return the length of the queue"""
return self.server.llen(self.key)

def push(self, request):
"""Push a request"""
self.server.lpush(self.key, self._encode_request(request))

def pop(self, timeout=0):
"""Pop a request"""
if timeout > 0:
data = self.server.brpop(self.key, timeout)
if isinstance(data, tuple):
data = data[1]
else:
data = self.server.rpop(self.key)
if data:
return self._decode_request(data)

可以看到这个类继承了 Base 类,并重写了 len()、push()、pop() 这三个方法,在这三个方法中都是对 server 对象的操作,而 server 对象就是一个 Redis 连接对象,我们可以直接调用其操作 Redis 的方法对数据库进行操作,可以看到这里的操作方法有 llen()、lpush()、rpop() 等,那这就代表此爬取队列是使用的 Redis 的列表,序列化后的 Request 会被存入列表中,就是列表的其中一个元素,len() 方法是获取列表的长度,push() 方法中调用了 lpush() 操作,这代表从列表左侧存入数据,pop() 方法中调用了 rpop() 操作,这代表从列表右侧取出数据。 所以 Request 在列表中的存取顺序是左侧进、右侧出,所以这是有序的进出,即先进先出,英文叫做 First Input First Output,也被简称作 Fifo,而此类的名称就叫做 FifoQueue。 另外还有一个与之相反的实现类,叫做 LifoQueue,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class LifoQueue(Base):
"""Per-spider LIFO queue."""

def __len__(self):
"""Return the length of the stack"""
return self.server.llen(self.key)

def push(self, request):
"""Push a request"""
self.server.lpush(self.key, self._encode_request(request))

def pop(self, timeout=0):
"""Pop a request"""
if timeout > 0:
data = self.server.blpop(self.key, timeout)
if isinstance(data, tuple):
data = data[1]
else:
data = self.server.lpop(self.key)

if data:
return self._decode_request(data)

与 FifoQueue 不同的就是它的 pop() 方法,在这里使用的是 lpop() 操作,也就是从左侧出,而 push() 方法依然是使用的 lpush() 操作,是从左侧入。那么这样达到的效果就是先进后出、后进先出,英文叫做 Last In First Out,简称为 Lifo,而此类名称就叫做 LifoQueue。同时这个存取方式类似栈的操作,所以其实也可以称作 StackQueue。 另外在源码中还有一个子类实现,叫做 PriorityQueue,顾名思义,它叫做优先级队列,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class PriorityQueue(Base):
"""Per-spider priority queue abstraction using redis' sorted set"""

def __len__(self):
"""Return the length of the queue"""
return self.server.zcard(self.key)

def push(self, request):
"""Push a request"""
data = self._encode_request(request)
score = -request.priority
self.server.execute_command('ZADD', self.key, score, data)

def pop(self, timeout=0):
"""
Pop a request
timeout not support in this queue class
"""
pipe = self.server.pipeline()
pipe.multi()
pipe.zrange(self.key, 0, 0).zremrangebyrank(self.key, 0, 0)
results, count = pipe.execute()
if results:
return self._decode_request(results[0])

在这里我们可以看到 len()、push()、pop() 方法中使用了 server 对象的 zcard()、zadd()、zrange() 操作,可以知道这里使用的存储结果是有序集合 Sorted Set,在这个集合中每个元素都可以设置一个分数,那么这个分数就代表优先级。 在 len() 方法里调用了 zcard() 操作,返回的就是有序集合的大小,也就是爬取队列的长度,在 push() 方法中调用了 zadd() 操作,就是向集合中添加元素,这里的分数指定成 Request 的优先级的相反数,因为分数低的会排在集合的前面,所以这里高优先级的 Request 就会存在集合的最前面。pop() 方法是首先调用了 zrange() 操作取出了集合的第一个元素,因为最高优先级的 Request 会存在集合最前面,所以第一个元素就是最高优先级的 Request,然后再调用 zremrangebyrank() 操作将这个元素删除,这样就完成了取出并删除的操作。 此队列是默认使用的队列,也就是爬取队列默认是使用有序集合来存储的。

3. 去重过滤

前面说过 Scrapy 的去重是利用集合来实现的,而在 Scrapy 分布式中的去重就需要利用共享的集合,那么这里使用的就是 Redis 中的集合数据结构。我们来看看去重类是怎样实现的,源码文件是 dupefilter.py,其内实现了一个 RFPDupeFilter 类,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
class RFPDupeFilter(BaseDupeFilter):
"""Redis-based request duplicates filter.
This class can also be used with default Scrapy's scheduler.
"""
logger = logger
def __init__(self, server, key, debug=False):
"""Initialize the duplicates filter.
Parameters
----------
server : redis.StrictRedis
The redis server instance.
key : str
Redis key Where to store fingerprints.
debug : bool, optional
Whether to log filtered requests.
"""
self.server = server
self.key = key
self.debug = debug
self.logdupes = True

@classmethod
def from_settings(cls, settings):
"""Returns an instance from given settings.
This uses by default the key ``dupefilter:<timestamp>``. When using the
``scrapy_redis.scheduler.Scheduler`` class, this method is not used as
it needs to pass the spider name in the key.
Parameters
----------
settings : scrapy.settings.Settings
Returns
-------
RFPDupeFilter
A RFPDupeFilter instance.
"""
server = get_redis_from_settings(settings)
key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())}
debug = settings.getbool('DUPEFILTER_DEBUG')
return cls(server, key=key, debug=debug)

@classmethod
def from_crawler(cls, crawler):
"""Returns instance from crawler.
Parameters
----------
crawler : scrapy.crawler.Crawler
Returns
-------
RFPDupeFilter
Instance of RFPDupeFilter.
"""
return cls.from_settings(crawler.settings)

def request_seen(self, request):
"""Returns True if request was already seen.
Parameters
----------
request : scrapy.http.Request
Returns
-------
bool
"""
fp = self.request_fingerprint(request)
added = self.server.sadd(self.key, fp)
return added == 0

def request_fingerprint(self, request):
"""Returns a fingerprint for a given request.
Parameters
----------
request : scrapy.http.Request

Returns
-------
str

"""
return request_fingerprint(request)

def close(self, reason=''):
"""Delete data on close. Called by Scrapy's scheduler.
Parameters
----------
reason : str, optional
"""
self.clear()

def clear(self):
"""Clears fingerprints data."""
self.server.delete(self.key)

def log(self, request, spider):
"""Logs given request.
Parameters
----------
request : scrapy.http.Request
spider : scrapy.spiders.Spider
"""
if self.debug:
msg = "Filtered duplicate request: %(request) s"
self.logger.debug(msg, {'request': request}, extra={'spider': spider})
elif self.logdupes:
msg = ("Filtered duplicate request %(request) s"
"- no more duplicates will be shown"
"(see DUPEFILTER_DEBUG to show all duplicates)")
self.logger.debug(msg, {'request': request}, extra={'spider': spider})
self.logdupes = False

这里同样实现了一个 request_seen() 方法,和 Scrapy 中的 request_seen() 方法实现极其类似。不过这里集合使用的是 server 对象的 sadd() 操作,也就是集合不再是一个简单数据结构了,而是直接换成了数据库的存储方式。 鉴别重复的方式还是使用指纹,指纹同样是依靠 request_fingerprint() 方法来获取的。获取指纹之后就直接向集合添加指纹,如果添加成功,说明这个指纹原本不存在于集合中,返回值 1。代码中最后的返回结果是判定添加结果是否为 0,如果刚才的返回值为 1,那这个判定结果就是 False,也就是不重复,否则判定为重复。 这样我们就成功利用 Redis 的集合完成了指纹的记录和重复的验证。

4. 调度器

Scrapy-Redis 还帮我们实现了配合 Queue、DupeFilter 使用的调度器 Scheduler,源文件名称是 scheduler.py。我们可以指定一些配置,如 SCHEDULER_FLUSH_ON_START 即是否在爬取开始的时候清空爬取队列,SCHEDULER_PERSIST 即是否在爬取结束后保持爬取队列不清除。我们可以在 settings.py 里自由配置,而此调度器很好地实现了对接。 接下来我们看看两个核心的存取方法,实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def enqueue_request(self, request):
if not request.dont_filter and self.df.request_seen(request):
self.df.log(request, self.spider)
return False
if self.stats:
self.stats.inc_value('scheduler/enqueued/redis', spider=self.spider)
self.queue.push(request)
return True

def next_request(self):
block_pop_timeout = self.idle_before_close
request = self.queue.pop(block_pop_timeout)
if request and self.stats:
self.stats.inc_value('scheduler/dequeued/redis', spider=self.spider)
return request

enqueue_request() 可以向队列中添加 Request,核心操作就是调用 Queue 的 push() 操作,还有一些统计和日志操作。next_request() 就是从队列中取 Request,核心操作就是调用 Queue 的 pop() 操作,此时如果队列中还有 Request,则 Request 会直接取出来,爬取继续,否则如果队列为空,爬取则会重新开始。

5. 总结

那么到现在为止我们就把之前所说的三个分布式的问题解决了,总结如下:

  • 爬取队列的实现,在这里提供了三种队列,使用了 Redis 的列表或有序集合来维护。
  • 去重的实现,使用了 Redis 的集合来保存 Request 的指纹来提供重复过滤。
  • 中断后重新爬取的实现,中断后 Redis 的队列没有清空,再次启动时调度器的 next_request() 会从队列中取到下一个 Request,继续爬取。

6. 结语

以上内容便是 Scrapy-Redis 的核心源码解析。Scrapy-Redis 中还提供了 Spider、Item Pipeline 的实现,不过它们并不是必须使用。 在下一节,我们会将 Scrapy-Redis 集成到之前所实现的 Scrapy 新浪微博项目中,实现多台主机协同爬取。

Python

14.1 分布式爬虫原理

我们在前面已经实现了 Scrapy 微博爬虫,虽然爬虫是异步加多线程的,但是我们只能在一台主机上运行,所以爬取效率还是有限的,分布式爬虫则是将多台主机组合起来,共同完成一个爬取任务,这将大大提高爬取的效率。

1. 分布式爬虫架构

在了解分布式爬虫架构之前,首先回顾一下 Scrapy 的架构,如图 13-1 所示。 Scrapy 单机爬虫中有一个本地爬取队列 Queue,这个队列是利用 deque 模块实现的。如果新的 Request 生成就会放到队列里面,随后 Request 被 Scheduler 调度。之后,Request 交给 Downloader 执行爬取,简单的调度架构如图 14-1 所示。 图 14-1 调度架构 如果两个 Scheduler 同时从队列里面取 Request,每个 Scheduler 都有其对应的 Downloader,那么在带宽足够、正常爬取且不考虑队列存取压力的情况下,爬取效率会有什么变化?没错,爬取效率会翻倍。 这样,Scheduler 可以扩展多个,Downloader 也可以扩展多个。而爬取队列 Queue 必须始终为一个,也就是所谓的共享爬取队列。这样才能保证 Scheduer 从队列里调度某个 Request 之后,其他 Scheduler 不会重复调度此 Request,就可以做到多个 Schduler 同步爬取。这就是分布式爬虫的基本雏形,简单调度架构如图 14-2 所示。 图 14-2 调度架构 我们需要做的就是在多台主机上同时运行爬虫任务协同爬取,而协同爬取的前提就是共享爬取队列。这样各台主机就不需要各自维护爬取队列,而是从共享爬取队列存取 Request。但是各台主机还是有各自的 Scheduler 和 Downloader,所以调度和下载功能分别完成。如果不考虑队列存取性能消耗,爬取效率还是会成倍提高。

2. 维护爬取队列

那么这个队列用什么维护来好呢?我们首先需要考虑的就是性能问题,什么数据库存取效率高?我们自然想到基于内存存储的 Redis,而且 Redis 还支持多种数据结构,例如列表 List、集合 Set、有序集合 Sorted Set 等等,存取的操作也非常简单,所以在这里我们采用 Redis 来维护爬取队列。 这几种数据结构存储实际各有千秋,分析如下:

  • 列表数据结构有 lpush()、lpop()、rpush()、rpop() 方法,所以我们可以用它来实现一个先进先出式爬取队列,也可以实现一个先进后出栈式爬取队列。
  • 集合的元素是无序的且不重复的,这样我们可以非常方便地实现一个随机排序的不重复的爬取队列。
  • 有序集合带有分数表示,而 Scrapy 的 Request 也有优先级的控制,所以用有集合我们可以实现一个带优先级调度的队列。

这些不同的队列我们需要根据具体爬虫的需求灵活选择。

3. 怎样来去重

Scrapy 有自动去重,它的去重使用了 Python 中的集合。这个集合记录了 Scrapy 中每个 Request 的指纹,这个指纹实际上就是 Request 的散列值。我们可以看看 Scrapy 的源代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import hashlib
def request_fingerprint(request, include_headers=None):
if include_headers:
include_headers = tuple(to_bytes(h.lower())
for h in sorted(include_headers))
cache = _fingerprint_cache.setdefault(request, {})
if include_headers not in cache:
fp = hashlib.sha1()
fp.update(to_bytes(request.method))
fp.update(to_bytes(canonicalize_url(request.url)))
fp.update(request.body or b'')
if include_headers:
for hdr in include_headers:
if hdr in request.headers:
fp.update(hdr)
for v in request.headers.getlist(hdr):
fp.update(v)
cache[include_headers] = fp.hexdigest()
return cache[include_headers]

request_fingerprint() 就是计算 Request 指纹的方法,其方法内部使用的是 hashlib 的 sha1() 方法。计算的字段包括 Request 的 Method、URL、Body、Headers 这几部分内容,这里只要有一点不同,那么计算的结果就不同。计算得到的结果是加密后的字符串,也就是指纹。每个 Request 都有独有的指纹,指纹就是一个字符串,判定字符串是否重复比判定 Request 对象是否重复容易得多,所以指纹可以作为判定 Request 是否重复的依据。 那么我们如何判定重复呢?Scrapy 是这样实现的,如下所示:

1
2
3
4
5
6
7
8
def __init__(self):
self.fingerprints = set()

def request_seen(self, request):
fp = self.request_fingerprint(request)
if fp in self.fingerprints:
return True
self.fingerprints.add(fp)

在去重的类 RFPDupeFilter 中,有一个 request_seen() 方法,这个方法有一个参数 request,它的作用就是检测该 Request 对象是否重复。这个方法调用 request_fingerprint() 获取该 Request 的指纹,检测这个指纹是否存在于 fingerprints 变量中,而 fingerprints 是一个集合,集合的元素都是不重复的。如果指纹存在,那么就返回 True,说明该 Request 是重复的,否则这个指纹加入到集合中。如果下次还有相同的 Request 传递过来,指纹也是相同的,那么这时指纹就已经存在于集合中,Request 对象就会直接判定为重复。这样去重的目的就实现了。 Scrapy 的去重过程就是,利用集合元素的不重复特性来实现 Request 的去重。 对于分布式爬虫来说,我们肯定不能再用每个爬虫各自的集合来去重了。因为这样还是每个主机单独维护自己的集合,不能做到共享。多台主机如果生成了相同的 Request,只能各自去重,各个主机之间就无法做到去重了。 那么要实现去重,这个指纹集合也需要是共享的,Redis 正好有集合的存储数据结构,我们可以利用 Redis 的集合作为指纹集合,那么这样去重集合也是利用 Redis 共享的。每台主机新生成 Request 之后,把该 Request 的指纹与集合比对,如果指纹已经存在,说明该 Request 是重复的,否则将 Request 的指纹加入到这个集合中即可。利用同样的原理不同的存储结构我们也实现了分布式 Reqeust 的去重。

4. 防止中断

在 Scrapy 中,爬虫运行时的 Request 队列放在内存中。爬虫运行中断后,这个队列的空间就被释放,此队列就被销毁了。所以一旦爬虫运行中断,爬虫再次运行就相当于全新的爬取过程。 要做到中断后继续爬取,我们可以将队列中的 Request 保存起来,下次爬取直接读取保存数据即可获取上次爬取的队列。我们在 Scrapy 中指定一个爬取队列的存储路径即可,这个路径使用 JOB_DIR 变量来标识,我们可以用如下命令来实现:

1
scrapy crawl spider -s JOBDIR=crawls/spider

更加详细的使用方法可以参见官方文档,链接为:https://doc.scrapy.org/en/latest/topics/jobs.html。 在 Scrapy 中,我们实际是把爬取队列保存到本地,第二次爬取直接读取并恢复队列即可。那么在分布式架构中我们还用担心这个问题吗?不需要。因为爬取队列本身就是用数据库保存的,如果爬虫中断了,数据库中的 Request 依然是存在的,下次启动就会接着上次中断的地方继续爬取。 所以,当 Redis 的队列为空时,爬虫会重新爬取;当 Redis 的队列不为空时,爬虫便会接着上次中断之处继续爬取。

5. 架构实现

我们接下来就需要在程序中实现这个架构了。首先实现一个共享的爬取队列,还要实现去重的功能。另外,重写一个 Scheduer 的实现,使之可以从共享的爬取队列存取 Request。 幸运的是,已经有人实现了这些逻辑和架构,并发布成叫 Scrapy-Redis 的 Python 包。接下来,我们看看 Scrapy-Redis 的源码实现,以及它的详细工作原理。

Python

13.13 Scrapy 爬取新浪微博

前面讲解了 Scrapy 中各个模块基本使用方法以及代理池、Cookies 池。接下来我们以一个反爬比较强的网站新浪微博为例,来实现一下 Scrapy 的大规模爬取。

1. 本节目标

本次爬取的目标是新浪微博用户的公开基本信息,如用户昵称、头像、用户的关注、粉丝列表以及发布的微博等,这些信息抓取之后保存至 MongoDB。

2. 准备工作

请确保前文所讲的代理池、Cookies 池已经实现并可以正常运行,安装 Scrapy、PyMongo 库,如没有安装可以参考前文内容。

3. 爬取思路

首先我们要实现用户的大规模爬取。这里采用的爬取方式是,以微博的几个大 V 为起始点,爬取他们各自的粉丝和关注列表,然后获取粉丝和关注列表的粉丝和关注列表,以此类推,这样下去就可以实现递归爬取。如果一个用户与其他用户有社交网络上的关联,那他们的信息就会被爬虫抓取到,这样我们就可以做到对所有用户的爬取。通过这种方式,我们可以得到用户的唯一 ID,再根据 ID 获取每个用户发布的微博即可。

4. 爬取分析

这里我们选取的爬取站点是:https://m.weibo.cn,此站点是微博移动端的站点。打开该站点会跳转到登录页面,这是因为主页做了登录限制。不过我们可以直接打开某个用户详情页面,如图 13-32 所示。 图 13-32 个人详情页面 我们在页面最上方可以看到她的关注和粉丝数量。我们点击关注,进入到她的关注列表,如图 13-33 所示。 图 13-33 关注列表 我们打开开发者工具,切换到 XHR 过滤器,一直下拉关注列表,即可看到下方会出现很多 Ajax 请求,这些请求就是获取关注列表的 Ajax 请求,如图 13-34 所示。 图 13-34 请求列表 我们打开第一个 Ajax 请求看一下,发现它的链接为:https://m.weibo.cn/api/container/getIndex?containerid=231051-_followers-_1916655407&luicode=10000011&lfid=1005051916655407&featurecode=20000320&type=uid&value=1916655407&page=2,详情如图 13-35 和 13-36 所示。 图 13-35 请求详情 图 13-36 响应结果 请求类型是 GET 类型,返回结果是 JSON 格式,我们将其展开之后即可看到其关注的用户的基本信息。接下来我们只需要构造这个请求的参数。此链接一共有 7 个参数,如图 13-37 所示。 图 13-37 参数信息 其中最主要的参数就是 containerid 和 page。有了这两个参数,我们同样可以获取请求结果。我们可以将接口精简为:https://m.weibo.cn/api/container/getIndex?containerid=231051-_followers-_1916655407&page=2,这里的 containerid 的前半部分是固定的,后半部分是用户的 id。所以这里参数就可以构造出来了,只需要修改 containerid 最后的 id 和 page 参数即可获取分页形式的关注列表信息。 利用同样的方法,我们也可以分析用户详情的 Ajax 链接、用户微博列表的 Ajax 链接,如下所示:

1
2
3
4
5
6
7
8
# 用户详情 API
user_url = 'https://m.weibo.cn/api/container/getIndex?uid={uid}&type=uid&value={uid}&containerid=100505{uid}'
# 关注列表 API
follow_url = 'https://m.weibo.cn/api/container/getIndex?containerid=231051_-_followers_-_{uid}&page={page}'
# 粉丝列表 API
fan_url = 'https://m.weibo.cn/api/container/getIndex?containerid=231051_-_fans_-_{uid}&page={page}'
# 微博列表 API
weibo_url = 'https://m.weibo.cn/api/container/getIndex?uid={uid}&type=uid&page={page}&containerid=107603{uid}'

此处的 uid 和 page 分别代表用户 ID 和分页页码。 注意,这个 API 可能随着时间的变化或者微博的改版而变化,以实测为准。 我们从几个大 V 开始抓取,抓取他们的粉丝、关注列表、微博信息,然后递归抓取他们的粉丝和关注列表的粉丝、关注列表、微博信息,递归抓取,最后保存微博用户的基本信息、关注和粉丝列表、发布的微博。 我们选择 MongoDB 作为存储的数据库,可以更方便地存储用户的粉丝和关注列表。

5. 新建项目

接下来,我们用 Scrapy 来实现这个抓取过程。首先创建一个项目,命令如下所示:

1
scrapy startproject weibo

进入项目中,新建一个 Spider,名为 weibocn,命令如下所示:

1
scrapy genspider weibocn m.weibo.cn

我们首先修改 Spider,配置各个 Ajax 的 URL,选取几个大 V,将他们的 ID 赋值成一个列表,实现 start_requests() 方法,也就是依次抓取各个大 V 的个人详情,然后用 parse_user() 进行解析,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from scrapy import Request, Spider

class WeiboSpider(Spider):
name = 'weibocn'
allowed_domains = ['m.weibo.cn']
user_url = 'https://m.weibo.cn/api/container/getIndex?uid={uid}&type=uid&value={uid}&containerid=100505{uid}'
follow_url = 'https://m.weibo.cn/api/container/getIndex?containerid=231051_-_followers_-_{uid}&page={page}'
fan_url = 'https://m.weibo.cn/api/container/getIndex?containerid=231051_-_fans_-_{uid}&page={page}'
weibo_url = 'https://m.weibo.cn/api/container/getIndex?uid={uid}&type=uid&page={page}&containerid=107603{uid}'
start_users = ['3217179555', '1742566624', '2282991915', '1288739185', '3952070245', '5878659096']

def start_requests(self):
for uid in self.start_users:
yield Request(self.user_url.format(uid=uid), callback=self.parse_user)

def parse_user(self, response):
self.logger.debug(response)

6. 创建 Item

接下来,我们解析用户的基本信息并生成 Item。这里我们先定义几个 Item,如用户、用户关系、微博的 Item,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from scrapy import Item, Field

class UserItem(Item):
collection = 'users'
id = Field()
name = Field()
avatar = Field()
cover = Field()
gender = Field()
description = Field()
fans_count = Field()
follows_count = Field()
weibos_count = Field()
verified = Field()
verified_reason = Field()
verified_type = Field()
follows = Field()
fans = Field()
crawled_at = Field()

class UserRelationItem(Item):
collection = 'users'
id = Field()
follows = Field()
fans = Field()

class WeiboItem(Item):
collection = 'weibos'
id = Field()
attitudes_count = Field()
comments_count = Field()
reposts_count = Field()
picture = Field()
pictures = Field()
source = Field()
text = Field()
raw_text = Field()
thumbnail = Field()
user = Field()
created_at = Field()
crawled_at = Field()

这里定义了 collection 字段,指明保存的 Collection 的名称。用户的关注和粉丝列表直接定义为一个单独的 UserRelationItem,其中 id 就是用户的 ID,follows 就是用户关注列表,fans 是粉丝列表,但这并不意味着我们会将关注和粉丝列表存到一个单独的 Collection 里。后面我们会用 Pipeline 对各个 Item 进行处理、合并存储到用户的 Collection 里,因此 Item 和 Collection 并不一定是完全对应的。

7. 提取数据

我们开始解析用户的基本信息,实现 parse_user() 方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def parse_user(self, response):
"""
解析用户信息
:param response: Response 对象
"""
result = json.loads(response.text)
if result.get('userInfo'):
user_info = result.get('userInfo')
user_item = UserItem()
field_map = {
'id': 'id', 'name': 'screen_name', 'avatar': 'profile_image_url', 'cover': 'cover_image_phone',
'gender': 'gender', 'description': 'description', 'fans_count': 'followers_count',
'follows_count': 'follow_count', 'weibos_count': 'statuses_count', 'verified': 'verified',
'verified_reason': 'verified_reason', 'verified_type': 'verified_type'
}
for field, attr in field_map.items():
user_item[field] = user_info.get(attr)
yield user_item
# 关注
uid = user_info.get('id')
yield Request(self.follow_url.format(uid=uid, page=1), callback=self.parse_follows,
meta={'page': 1, 'uid': uid})
# 粉丝
yield Request(self.fan_url.format(uid=uid, page=1), callback=self.parse_fans,
meta={'page': 1, 'uid': uid})
# 微博
yield Request(self.weibo_url.format(uid=uid, page=1), callback=self.parse_weibos,
meta={'page': 1, 'uid': uid})

在这里我们一共完成了两个操作。

  • 解析 JSON 提取用户信息并生成 UserItem 返回。我们并没有采用常规的逐个赋值的方法,而是定义了一个字段映射关系。我们定义的字段名称可能和 JSON 中用户的字段名称不同,所以在这里定义成一个字典,然后遍历字典的每个字段实现逐个字段的赋值。
  • 构造用户的关注、粉丝、微博的第一页的链接,并生成 Request,这里需要的参数只有用户的 ID。另外,初始分页页码直接设置为 1 即可。

接下来,我们还需要保存用户的关注和粉丝列表。以关注列表为例,其解析方法为 parse_follows(),实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def parse_follows(self, response):
"""
解析用户关注
:param response: Response 对象
"""
result = json.loads(response.text)
if result.get('ok') and result.get('cards') and len(result.get('cards')) and result.get('cards')[-1].get('card_group'):
# 解析用户
follows = result.get('cards')[-1].get('card_group')
for follow in follows:
if follow.get('user'):
uid = follow.get('user').get('id')
yield Request(self.user_url.format(uid=uid), callback=self.parse_user)
# 关注列表
uid = response.meta.get('uid')
user_relation_item = UserRelationItem()
follows = [{'id': follow.get('user').get('id'), 'name': follow.get('user').get('screen_name')} for follow in
follows]
user_relation_item['id'] = uid
user_relation_item['follows'] = follows
user_relation_item['fans'] = []
yield user_relation_item
# 下一页关注
page = response.meta.get('page') + 1
yield Request(self.follow_url.format(uid=uid, page=page),
callback=self.parse_follows, meta={'page': page, 'uid': uid})

那么在这个方法里面我们做了如下三件事。

  • 解析关注列表中的每个用户信息并发起新的解析请求。我们首先解析关注列表的信息,得到用户的 ID,然后再利用 user_url 构造访问用户详情的 Request,回调就是刚才所定义的 parse_user() 方法。
  • 提取用户关注列表内的关键信息并生成 UserRelationItem。id 字段直接设置成用户的 ID,JSON 返回数据中的用户信息有很多冗余字段。在这里我们只提取了关注用户的 ID 和用户名,然后把它们赋值给 follows 字段,fans 字段设置成空列表。这样我们就建立了一个存有用户 ID 和用户部分关注列表的 UserRelationItem,之后合并且保存具有同一个 ID 的 UserRelationItem 的关注和粉丝列表。
  • 提取下一页关注。只需要将此请求的分页页码加 1 即可。分页页码通过 Request 的 meta 属性进行传递,Response 的 meta 来接收。这样我们构造并返回下一页的关注列表的 Request。

抓取粉丝列表的原理和抓取关注列表原理相同,在此不再赘述。 接下来我们还差一个方法的实现,即 parse_weibos(),它用来抓取用户的微博信息,实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def parse_weibos(self, response):
"""
解析微博列表
:param response: Response 对象
"""
result = json.loads(response.text)
if result.get('ok') and result.get('cards'):
weibos = result.get('cards')
for weibo in weibos:
mblog = weibo.get('mblog')
if mblog:
weibo_item = WeiboItem()
field_map = {
'id': 'id', 'attitudes_count': 'attitudes_count', 'comments_count': 'comments_count', 'created_at': 'created_at',
'reposts_count': 'reposts_count', 'picture': 'original_pic', 'pictures': 'pics',
'source': 'source', 'text': 'text', 'raw_text': 'raw_text', 'thumbnail': 'thumbnail_pic'
}
for field, attr in field_map.items():
weibo_item[field] = mblog.get(attr)
weibo_item['user'] = response.meta.get('uid')
yield weibo_item
# 下一页微博
uid = response.meta.get('uid')
page = response.meta.get('page') + 1
yield Request(self.weibo_url.format(uid=uid, page=page), callback=self.parse_weibos,
meta={'uid': uid, 'page': page})

这里 parse_weibos() 方法完成了两件事。

  • 提取用户的微博信息,并生成 WeiboItem。这里同样建立了一个字段映射表,实现批量字段赋值。
  • 提取下一页的微博列表。这里同样需要传入用户 ID 和分页页码。

到目前为止,微博的 Spider 已经完成。后面还需要对数据进行数据清洗存储,以及对接代理池、Cookies 池来防止反爬虫。

8. 数据清洗

有些微博的时间可能不是标准的时间,比如它可能显示为刚刚、几分钟前、几小时前、昨天等。这里我们需要统一转化这些时间,实现一个 parse_time() 方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def parse_time(self, date):
if re.match(' 刚刚 ', date):
date = time.strftime('% Y-% m-% d % H:% M', time.localtime(time.time()))
if re.match('d + 分钟前 ', date):
minute = re.match('(d+)', date).group(1)
date = time.strftime('% Y-% m-% d % H:% M', time.localtime(time.time() - float(minute) * 60))
if re.match('d + 小时前 ', date):
hour = re.match('(d+)', date).group(1)
date = time.strftime('% Y-% m-% d % H:% M', time.localtime(time.time() - float(hour) * 60 * 60))
if re.match(' 昨天.*', date):
date = re.match(' 昨天 (.*)', date).group(1).strip()
date = time.strftime('% Y-% m-% d', time.localtime() - 24 * 60 * 60) + ' ' + date
if re.match('d{2}-d{2}', date):
date = time.strftime('% Y-', time.localtime()) + date + ' 00:00'
return date

我们用正则来提取一些关键数字,用 time 库来实现标准时间的转换。 以 X 分钟前的处理为例,爬取的时间会赋值为 created_at 字段。我们首先用正则匹配这个时间,表达式写作 d + 分钟前,如果提取到的时间符合这个表达式,那么就提取出其中的数字,这样就可以获取分钟数了。接下来使用 time 模块的 strftime() 方法,第一个参数传入要转换的时间格式,第二个参数就是时间戳。这里我们用当前的时间戳减去此分钟数乘以 60 就是当时的时间戳,这样我们就可以得到格式化后的正确时间了。 然后 Pipeline 可以实现如下处理:

1
2
3
4
5
6
class WeiboPipeline():
def process_item(self, item, spider):
if isinstance(item, WeiboItem):
if item.get('created_at'):
item['created_at'] = item['created_at'].strip()
item['created_at'] = self.parse_time(item.get('created_at'))

我们在 Spider 里没有对 crawled_at 字段赋值,它代表爬取时间,我们可以统一将其赋值为当前时间,实现如下所示:

1
2
3
4
5
6
class TimePipeline():
def process_item(self, item, spider):
if isinstance(item, UserItem) or isinstance(item, WeiboItem):
now = time.strftime('% Y-% m-% d % H:% M', time.localtime())
item['crawled_at'] = now
return item

这里我们判断了 item 如果是 UserItem 或 WeiboItem 类型,那么就给它的 crawled_at 字段赋值为当前时间。 通过上面的两个 Pipeline,我们便完成了数据清洗工作,这里主要是时间的转换。

9. 数据存储

数据清洗完毕之后,我们就要将数据保存到 MongoDB 数据库。我们在这里实现 MongoPipeline 类,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import pymongo

class MongoPipeline(object):
def __init__(self, mongo_uri, mongo_db):
self.mongo_uri = mongo_uri
self.mongo_db = mongo_db

@classmethod
def from_crawler(cls, crawler):
return cls(mongo_uri=crawler.settings.get('MONGO_URI'), mongo_db=crawler.settings.get('MONGO_DATABASE')
)

def open_spider(self, spider):
self.client = pymongo.MongoClient(self.mongo_uri)
self.db = self.client[self.mongo_db]
self.db[UserItem.collection].create_index([('id', pymongo.ASCENDING)])
self.db[WeiboItem.collection].create_index([('id', pymongo.ASCENDING)])

def close_spider(self, spider):
self.client.close()

def process_item(self, item, spider):
if isinstance(item, UserItem) or isinstance(item, WeiboItem):
self.db[item.collection].update({'id': item.get('id')}, {'$set': item}, True)
if isinstance(item, UserRelationItem):
self.db[item.collection].update({'id': item.get('id')},
{'$addToSet':
{'follows': {'$each': item['follows']},
'fans': {'$each': item['fans']}
}
}, True)
return item

当前的 MongoPipeline 和前面我们所写的有所不同,主要有以下几点。

  • 在 open_spider() 方法里面添加了 Collection 的索引,在这里为两个 Item 都做了索引,索引的字段是 id,由于我们这次是大规模爬取,同时在爬取过程中涉及到数据的更新问题,所以我们为每个 Collection 建立了索引,建立了索引之后可以大大提高检索效率。
  • 在 process_item() 方法里存储使用的是 update() 方法,第一个参数是查询条件,第二个参数是爬取的 Item,这里我们使用了 $set 操作符,这样我们如果爬取到了重复的数据即可对数据进行更新,同时不会删除已存在的字段,如果这里不加 $set 操作符,那么会直接进行 item 替换,这样可能会导致已存在的字段如关注和粉丝列表清空,所以这里必须要加上 $set 操作符。第三个参数我们设置为了 True,这个参数起到的作用是如果数据不存在,则插入数据。这样我们就可以做到数据存在即更新、数据不存在即插入,这样就达到了去重的效果。
  • 对于用户的关注和粉丝列表,我们在这里使用了一个新的操作符,叫做 $addToSet,这个操作符可以向列表类型的字段插入数据同时去重,接下来它的值就是需要操作的字段名称,我们在这里又利用了 $each 操作符对需要插入的列表数据进行了遍历,这样就可以逐条插入用户的关注或粉丝数据到指定的字段了,关于该操作更多的解释可以参考 MongoDB 的官方文档,链接为:https://docs.mongodb.com/manual/reference/operator/update/addToSet/

10. Cookies 池对接

新浪微博的反爬能力非常强,我们需要做一些防范反爬虫的措施才可以顺利完成数据爬取。 如果没有登录而直接请求微博的 API 接口,这非常容易导致 403 状态码。这个情况我们在 10.2 节也提过。所以在这里我们实现一个 Middleware,为每个 Request 添加随机的 Cookies。 我们先开启 Cookies 池,使 API 模块正常运行。例如在本地运行 5000 端口,访问:http://localhost:5000/weibo/random 即可获取随机的 Cookies,当然也可以将 Cookies 池部署到远程的服务器,这样只需要更改一下访问的链接就好了。 那么在这里我们将 Cookies 池在本地启动起来,再实现一个 Middleware 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class CookiesMiddleware():
def __init__(self, cookies_url):
self.logger = logging.getLogger(__name__)
self.cookies_url = cookies_url

def get_random_cookies(self):
try:
response = requests.get(self.cookies_url)
if response.status_code == 200:
cookies = json.loads(response.text)
return cookies
except requests.ConnectionError:
return False

def process_request(self, request, spider):
self.logger.debug(' 正在获取 Cookies')
cookies = self.get_random_cookies()
if cookies:
request.cookies = cookies
self.logger.debug(' 使用 Cookies ' + json.dumps(cookies))

@classmethod
def from_crawler(cls, crawler):
settings = crawler.settings
return cls(cookies_url=settings.get('COOKIES_URL')
)

我们首先利用 from_crawler() 方法获取了 COOKIES_URL 变量,它定义在 settings.py 里,这就是刚才我们所说的接口。接下来实现 get_random_cookies() 方法,这个方法主要就是请求此 Cookies 池接口并获取接口返回的随机 Cookies。如果成功获取,则返回 Cookies;否则返回 False。 接下来,在 process_request() 方法里,我们给 request 对象的 cookies 属性赋值,其值就是获取的随机 Cookies,这样我们就成功地为每一次请求赋值 Cookies 了。 如果启用了该 Middleware,每个请求都会被赋值随机的 Cookies。这样我们就可以模拟登录之后的请求,403 状态码基本就不会出现。

11. 代理池对接

微博还有一个反爬措施就是,检测到同一 IP 请求量过大时就会出现 414 状态码。如果遇到这样的情况可以切换代理。例如,在本地 5555 端口运行,获取随机可用代理的地址为:http://localhost:5555/random,访问这个接口即可获取一个随机可用代理。接下来我们再实现一个 Middleware,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ProxyMiddleware():
def __init__(self, proxy_url):
self.logger = logging.getLogger(__name__)
self.proxy_url = proxy_url

def get_random_proxy(self):
try:
response = requests.get(self.proxy_url)
if response.status_code == 200:
proxy = response.text
return proxy
except requests.ConnectionError:
return False

def process_request(self, request, spider):
if request.meta.get('retry_times'):
proxy = self.get_random_proxy()
if proxy:
uri = 'https://{proxy}'.format(proxy=proxy)
self.logger.debug(' 使用代理 ' + proxy)
request.meta['proxy'] = uri

@classmethod
def from_crawler(cls, crawler):
settings = crawler.settings
return cls(proxy_url=settings.get('PROXY_URL')
)

同样的原理,我们实现了一个 get_random_proxy() 方法用于请求代理池的接口获取随机代理。如果获取成功,则返回改代理,否则返回 False。在 process_request() 方法中,我们给 request 对象的 meta 属性赋值一个 proxy 字段,该字段的值就是代理。 另外,赋值代理的判断条件是当前 retry_times 不为空,也就是说第一次请求失败之后才启用代理,因为使用代理后访问速度会慢一些。所以我们在这里设置了只有重试的时候才启用代理,否则直接请求。这样就可以保证在没有被封禁的情况下直接爬取,保证了爬取速度。

12. 启用 Middleware

接下来,我们在配置文件中启用这两个 Middleware,修改 settings.py 如下所示:

1
2
3
4
DOWNLOADER_MIDDLEWARES = {
'weibo.middlewares.CookiesMiddleware': 554,
'weibo.middlewares.ProxyMiddleware': 555,
}

注意这里的优先级设置,前文提到了 Scrapy 的默认 Downloader Middleware 的设置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware': 100,
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware': 300,
'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware': 350,
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware': 400,
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': 500,
'scrapy.downloadermiddlewares.retry.RetryMiddleware': 550,
'scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware': 560,
'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware': 580,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 590,
'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': 600,
'scrapy.downloadermiddlewares.cookies.CookiesMiddleware': 700,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 750,
'scrapy.downloadermiddlewares.stats.DownloaderStats': 850,
'scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware': 900,
}

要使得我们自定义的 CookiesMiddleware 生效,它在内置的 CookiesMiddleware 之前调用。内置的 CookiesMiddleware 的优先级为 700,所以这里我们设置一个比 700 小的数字即可。 要使得我们自定义的 ProxyMiddleware 生效,它在内置的 HttpProxyMiddleware 之前调用。内置的 HttpProxyMiddleware 的优先级为 750,所以这里我们设置一个比 750 小的数字即可。

13. 运行

到此为止,整个微博爬虫就实现完毕了,我们运行如下命令启动一下爬虫:

1
scrapy crawl weibocn

类似的输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2017-07-11 17:27:34 [urllib3.connectionpool] DEBUG: http://localhost:5000 "GET /weibo/random HTTP/1.1" 200 339
2017-07-11 17:27:34 [weibo.middlewares] DEBUG: 使用 Cookies {"SCF": "AhzwTr_DxIGjgri_dt46_DoPzUqq-PSupu545JdozdHYJ7HyEb4pD3pe05VpbIpVyY1ciKRRWwUgojiO3jYwlBE.", "_T_WM": "8fe0bc1dad068d09b888d8177f1c1218", "SSOLoginState": "1501496388", "M_WEIBOCN_PARAMS": "uicode%3D20000174", "SUHB": "0tKqV4asxqYl4J", "SUB": "_2A250e3QUDeRhGeBM6VYX8y7NwjiIHXVXhBxcrDV6PUJbkdBeLXjckW2fUT8MWloekO4FCWVlIYJGJdGLnA.."}
2017-07-11 17:27:34 [weibocn] DEBUG: <200 https://m.weibo.cn/api/container/getIndex?uid=1742566624&type=uid&value=1742566624&containerid=1005051742566624>
2017-07-11 17:27:34 [scrapy.core.scraper] DEBUG: Scraped from <200 https://m.weibo.cn/api/container/getIndex?uid=1742566624&type=uid&value=1742566624&containerid=1005051742566624>
{'avatar': 'https://tva4.sinaimg.cn/crop.0.0.180.180.180/67dd74e0jw1e8qgp5bmzyj2050050aa8.jpg',
'cover': 'https://tva3.sinaimg.cn/crop.0.0.640.640.640/6ce2240djw1e9oaqhwllzj20hs0hsdir.jpg',
'crawled_at': '2017-07-11 17:27',
'description': ' 成长,就是一个不断觉得以前的自己是个傻逼的过程 ',
'fans_count': 19202906,
'follows_count': 1599,
'gender': 'm',
'id': 1742566624,
'name': ' 思想聚焦 ',
'verified': True,
'verified_reason': ' 微博知名博主,校导网编辑 ',
'verified_type': 0,
'weibos_count': 58393}

运行一段时间后,我们便可以到 MongoDB 数据库查看数据,爬取下来的数据如图 13-38 和图 13-39 所示。 图 13-38 用户信息 图 13-39 微博信息 针对用户信息,我们不仅爬取了其基本信息,还把关注和粉丝列表加到了 follows 和 fans 字段并做了去重操作。针对微博信息,我们成功进行了时间转换处理,同时还保存了微博的图片列表信息。

14. 本节代码

本节代码地址:https://github.com/Python3WebSpider/Weibo

15. 结语

本节实现了新浪微博的用户及其粉丝关注列表和微博信息的爬取,还对接了 Cookies 池和代理池来处理反爬虫。不过现在是针对单机的爬取,后面我们会将此项目修改为分布式爬虫,以进一步提高抓取效率。

Python

13.12 Scrapy 对接 Docker

环境配置问题可能一直是我们头疼的,我们可能遇到过如下的情况:

  • 我们在本地写好了一个 Scrapy 爬虫项目,想要把它放到服务器上运行,但是服务器上没有安装 Python 环境。
  • 别人给了我们一个 Scrapy 爬虫项目,项目中使用包的版本和我们本地环境版本不一致,无法直接运行。
  • 我们需要同时管理不同版本的 Scrapy 项目,如早期的项目依赖于 Scrapy 0.25,现在的项目依赖于 Scrapy 1.4.0。

在这些情况下,我们需要解决的就是环境的安装配置、环境的版本冲突解决等问题。 对于 Python 来说,VirtualEnv 的确可以解决版本冲突的问题。但是,VirtualEnv 不太方便做项目部署,我们还是需要安装 Python 环境, 如何解决上述问题呢?答案是用 Docker。Docker 可以提供操作系统级别的虚拟环境,一个 Docker 镜像一般都包含一个完整的操作系统,而这些系统内也有已经配置好的开发环境,如 Python 3.6 环境等。 我们可以直接使用此 Docker 的 Python 3 镜像运行一个容器,将项目直接放到容器里运行,就不用再额外配置 Python 3 环境。这样就解决了环境配置的问题。 我们也可以进一步将 Scrapy 项目制作成一个新的 Docker 镜像,镜像里只包含适用于本项目的 Python 环境。如果要部署到其他平台,只需要下载该镜像并运行就好了,因为 Docker 运行时采用虚拟环境,和宿主机是完全隔离的,所以也不需要担心环境冲突问题。 如果我们能够把 Scrapy 项目制作成一个 Docker 镜像,只要其他主机安装了 Docker,那么只要将镜像下载并运行即可,而不必再担心环境配置问题或版本冲突问题。 接下来,我们尝试把一个 Scrapy 项目制作成一个 Docker 镜像。

1. 本节目标

我们要实现把前文 Scrapy 的入门项目打包成一个 Docker 镜像的过程。项目爬取的网址为:http://quotes.toscrape.com/,本章 Scrapy 入门一节已经实现了 Scrapy 对此站点的爬取过程,项目代码为:https://github.com/Python3WebSpider/ScrapyTutorial,如果本地不存在的话可以 Clone 下来。

2. 准备工作

请确保已经安装好 Docker 和 MongoDB 并可以正常运行,如果没有安装可以参考第 1 章的安装说明。

3. 创建 Dockerfile

首先在项目的根目录下新建一个 requirements.txt 文件,将整个项目依赖的 Python 环境包都列出来,如下所示:

1
2
scrapy
pymongo

如果库需要特定的版本,我们还可以指定版本号,如下所示:

1
2
scrapy>=1.4.0
pymongo>=3.4.0

在项目根目录下新建一个 Dockerfile 文件,文件不加任何后缀名,修改内容如下所示:

1
2
3
4
5
6
FROM python:3.6
ENV PATH /usr/local/bin:$PATH
ADD . /code
WORKDIR /code
RUN pip3 install -r requirements.txt
CMD scrapy crawl quotes

第一行的 FROM 代表使用的 Docker 基础镜像,在这里我们直接使用 python:3.6 的镜像,在此基础上运行 Scrapy 项目。 第二行 ENV 是环境变量设置,将 /usr/local/bin:$PATH 赋值给 PATH,即增加 /usr/local/bin 这个环境变量路径。 第三行 ADD 是将本地的代码放置到虚拟容器中。它有两个参数:第一个参数是.,代表本地当前路径;第二个参数是 /code,代表虚拟容器中的路径,也就是将本地项目所有内容放置到虚拟容器的 /code 目录下,以便于在虚拟容器中运行代码。 第四行 WORKDIR 是指定工作目录,这里将刚才添加的代码路径设成工作路径。这个路径下的目录结构和当前本地目录结构是相同的,所以我们可以直接执行库安装命令、爬虫运行命令等。 第五行 RUN 是执行某些命令来做一些环境准备工作。由于 Docker 虚拟容器内只有 Python 3 环境,而没有所需要的 Python 库,所以我们运行此命令来在虚拟容器中安装相应的 Python 库如 Scrapy,这样就可以在虚拟容器中执行 Scrapy 命令了。 第六行 CMD 是容器启动命令。在容器运行时,此命令会被执行。在这里我们直接用 scrapy crawl quotes 来启动爬虫。

4. 修改 MongoDB 连接

接下来我们需要修改 MongoDB 的连接信息。如果我们继续用 localhost 是无法找到 MongoDB 的,因为在 Docker 虚拟容器里 localhost 实际指向容器本身的运行 IP,而容器内部并没有安装 MongoDB,所以爬虫无法连接 MongoDB。 这里的 MongoDB 地址可以有如下两种选择。

  • 如果只想在本机测试,我们可以将地址修改为宿主机的 IP,也就是容器外部的本机 IP,一般是一个局域网 IP,使用 ifconfig 命令即可查看。
  • 如果要部署到远程主机运行,一般 MongoDB 都是可公网访问的地址,修改为此地址即可。

在本节中,我们的目标是将项目打包成一个镜像,让其他远程主机也可运行这个项目。所以我们直接将此处 MongoDB 地址修改为某个公网可访问的远程数据库地址,修改 MONGO_URI 如下所示:

1
MONGO_URI = 'mongodb://admin:admin123@120.27.34.25:27017'

此处地址可以修改为自己的远程 MongoDB 数据库地址。 这样项目的配置就完成了。

5. 构建镜像

接下来我们便可以构建镜像了,执行如下命令:

1
docker build -t quotes:latest .

这样的输出就说明镜像构建成功。这时我们查看一下构建的镜像,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Sending build context to Docker daemon 191.5 kB
Step 1/6 : FROM python:3.6
---> 968120d8cbe8
Step 2/6 : ENV PATH /usr/local/bin:$PATH
---> Using cache
---> 387abbba1189
Step 3/6 : ADD . /code
---> a844ee0db9c6
Removing intermediate container 4dc41779c573
Step 4/6 : WORKDIR /code
---> 619b2c064ae9
Removing intermediate container bcd7cd7f7337
Step 5/6 : RUN pip3 install -r requirements.txt
---> Running in 9452c83a12c5
...
Removing intermediate container 9452c83a12c5
Step 6/6 : CMD scrapy crawl quotes
---> Running in c092b5557ab8
---> c8101aca6e2a
Removing intermediate container c092b5557ab8
Successfully built c8101aca6e2a

出现类似输出就证明镜像构建成功了,这时执行如我们查看一下构建的镜像:

1
docker images

返回结果中其中有一行就是:

1
quotes  latest  41c8499ce210    2 minutes ago   769 MB

这就是我们新构建的镜像。

6. 运行

我们可以先在本地测试运行,执行如下命令:

1
docker run quotes

这样我们就利用此镜像新建并运行了一个 Docker 容器,运行效果完全一致,如图 13-29 所示。 图 13-32 运行结果 如果出现类似图 13-29 的运行结果,这就证明构建的镜像没有问题。

7. 推送至 Docker Hub

构建完成之后,我们可以将镜像 Push 到 Docker 镜像托管平台,如 Docker Hub 或者私有的 Docker Registry 等,这样我们就可以从远程服务器下拉镜像并运行了。 以 Docker Hub 为例,如果项目包含一些私有的连接信息(如数据库),我们最好将 Repository 设为私有或者直接放到私有的 Docker Registry。 首先在 https://hub.docker.com 注册一个账号,新建一个 Repository,名为 quotes。比如,我的用户名为 germey,新建的 Repository 名为 quotes,那么此 Repository 的地址就可以用 germey/quotes 来表示。 为新建的镜像打一个标签,命令如下所示:

1
docker tag quotes:latest germey/quotes:latest

推送镜像到 Docker Hub 即可,命令如下所示:

1
docker push germey/quotes

Docker Hub 便会出现新推送的 Docker 镜像了,如图 13-30 所示。 图 13-30 推送结果 如果我们想在其他的主机上运行这个镜像,主机上装好 Docker 后,可以直接执行如下命令:

1
docker run germey/quotes

这样就会自动下载镜像,然后启动容器运行,不需要配置 Python 环境,不需要关心版本冲突问题。 运行效果如图 13-31 所示: 图 13-31 运行效果 整个项目爬取完成后,数据就可以存储到指定的数据库中。

8. 结语

我们讲解了将 Scrapy 项目制作成 Docker 镜像并部署到远程服务器运行的过程。使用此种方式,我们在本节开头所列出的问题都迎刃而解。

Python

13.11 Scrapyrt 的使用

Scrapyrt 为 Scrapy 提供了一个调度的 HTTP 接口。有了它我们不需要再执行 Scrapy 命令,而是通过请求一个 HTTP 接口即可调度 Scrapy 任务,我们就不需要借助于命令行来启动项目了。如果项目是在远程服务器运行,利用它来启动项目是个不错的选择。

1. 本节目标

我们以本章 Scrapy 入门项目为例来说明 Scrapyrt 的使用方法,项目源代码地址为:https://github.com/Python3WebSpider/ScrapyTutorial

2. 准备工作

请确保 Scrapyrt 已经正确安装并正常运行,具体安装可以参考第 1 章的说明。

3. 启动服务

首先将项目下载下来,在项目目录下运行 Scrapyrt,假设当前服务运行在 9080 端口上。下面将简单介绍 Scrapyrt 的使用方法。

4. GET 请求

目前,GET 请求方式支持如下的参数。

  • spider_name,Spider 名称,字符串类型,必传参数,如果传递的 Spider 名称不存在则会返回 404 错误。
  • url,爬取链接,字符串类型,如果起始链接没有定义的话就必须要传递,如果传递了该参数,Scrapy 会直接用该 URL 生成 Request,而直接忽略 start_requests() 方法和 start_urls 属性的定义。
  • callback,回调函数名称,字符串类型,可选参数,如果传递了就会使用此回调函数处理,否则会默认使用 Spider 内定义的回调函数。
  • max_requests,最大请求数量,数值类型,可选参数,它定义了 Scrapy 执行请求的 Request 的最大限制,如定义为 5,则最多只执行 5 次 Request 请求,其余的则会被忽略。
  • start_requests,是否要执行 start_request() 函数,布尔类型,可选参数,在 Scrapy 项目中如果定义了 start_requests() 方法,那么在项目启动时会默认调用该方法,但是在 Scrapyrt 就不一样了,它默认不执行 start_requests() 方法,如果要执行,需要将它设置为 true。

例如我们执行如下命令:

1
curl http://localhost:9080/crawl.json?spider_name=quotes&url=http://quotes.toscrape.com/

得到类似如下结果,如图 13-28 所示: 图 13-28 输出结果 返回的是一个 JSON 格式的字符串,我们解析它的结构,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{
"status": "ok",
"items": [
{
"text": "“The world as we have created it is a process of o...",
"author": "Albert Einstein",
"tags": [
"change",
"deep-thoughts",
"thinking",
"world"
]
},
...
{
"text": "“... a mind needs books as a sword needs a whetsto...",
"author": "George R.R. Martin",
"tags": [
"books",
"mind"
]
}
],
"items_dropped": [],
"stats": {
"downloader/request_bytes": 2892,
"downloader/request_count": 11,
"downloader/request_method_count/GET": 11,
"downloader/response_bytes": 24812,
"downloader/response_count": 11,
"downloader/response_status_count/200": 10,
"downloader/response_status_count/404": 1,
"dupefilter/filtered": 1,
"finish_reason": "finished",
"finish_time": "2017-07-12 15:09:02",
"item_scraped_count": 100,
"log_count/DEBUG": 112,
"log_count/INFO": 8,
"memusage/max": 52510720,
"memusage/startup": 52510720,
"request_depth_max": 10,
"response_received_count": 11,
"scheduler/dequeued": 10,
"scheduler/dequeued/memory": 10,
"scheduler/enqueued": 10,
"scheduler/enqueued/memory": 10,
"start_time": "2017-07-12 15:08:56"
},
"spider_name": "quotes"
}

这里省略了 items 绝大部分。status 显示了爬取的状态,items 部分是 Scrapy 项目的爬取结果,items_dropped 是被忽略的 Item 列表,stats 是爬取结果的统计情况。此结果和直接运行 Scrapy 项目得到的统计是相同的。 这样一来,我们就通过 HTTP 接口调度 Scrapy 项目并获取爬取结果,如果 Scrapy 项目部署在服务器上,我们可以通过开启一个 Scrapyrt 服务实现任务的调度并直接取到爬取结果,这很方便。

5. POST 请求

除了 GET 请求,我们还可以通过 POST 请求来请求 Scrapyrt。但是此处 Request Body 必须是一个合法的 JSON 配置,在 JSON 里面可以配置相应的参数,支持的配置参数更多。 目前,JSON 配置支持如下参数。

  • spider_name:Spider 名称,字符串类型,必传参数。如果传递的 Spider 名称不存在,则返回 404 错误。
  • max_requests:最大请求数量,数值类型,可选参数。它定义了 Scrapy 执行请求的 Request 的最大限制,如定义为 5,则表示最多只执行 5 次 Request 请求,其余的则会被忽略。
  • request:Request 配置,JSON 对象,必传参数。通过该参数可以定义 Request 的各个参数,必须指定 url 字段来指定爬取链接,其他字段可选。

我们看一个 JSON 配置实例,如下所示:

1
2
3
4
5
6
7
8
9
10
{
"request": {
"url": "http://quotes.toscrape.com/",
"callback": "parse",
"dont_filter": "True",
"cookies": {"foo": "bar"}
},
"max_requests": 2,
"spider_name": "quotes"
}

我们执行如下命令传递该 Json 配置并发起 POST 请求:

1
curl http://localhost:9080/crawl.json -d '{"request": {"url": "http://quotes.toscrape.com/", "dont_filter": "True", "callback": "parse", "cookies": {"foo": "bar"}}, "max_requests": 2, "spider_name": "quotes"}'

运行结果和上文类似,同样是输出了爬取状态、结果、统计信息等内容。

6. 结语

以上内容便是 Scrapyrt 的相关用法介绍。通过它,我们方便地调度 Scrapy 项目的运行并获取爬取结果。更多的使用方法可以参考官方文档:http://scrapyrt.readthedocs.io

技术杂谈

最近在开发过程中遇到了这么一个问题: 现在有一个 Web 项目,前端是使用 Vue.js 开发的,整个前端需要部署到 K8S 上,后端和前端分开,同样也需要部署到 K8S 上,因此二者需要打包为 Docker 镜像。 对前端来说,打包 Docker 就遇到了一个问题:跨域访问问题。 因此一个普遍的解决方案就是使用 Nginx 做反向代理。 一般来说,我们需要在打包时配置一下 nginx.conf 文件,然后在 Dockerfile 里面指定即可。

Dockerfile

首先看下 Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# build stage
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# production stage
FROM nginx:lts-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/
RUN rm /etc/nginx/conf.d/default.conf
&& mv /etc/nginx/conf.d/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

一般来说,对于常规的 Vue.js 前端项目,Dockerfile 就这么写就行了。 简单介绍一下:

  • 第一步,使用 Node.js 镜像,在 Node.js 环境下对项目进行编译,默认会输出到 dist 文件夹下。
  • 第二步,使用新的 Nginx 镜像,将编译得到的前端文件拷贝到 nginx 默认 serve 的目录,然后把自定义的 nginx.conf 文件替换为 Nginx 默认的 conf 文件,运行即可。

反向代理

这里比较关键的就是 nginx.conf 文件了,为了解决跨域问题,我们一般会将后端的接口进行反向代理。 一般来说,后端的 API 接口都是以 api 为开头的,所以我们需要代理 api 开头的接口地址,nginx.conf 内容一般可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server {
listen 80;
server_name localhost;

location /api/ {
proxy_pass http://domain.com/api/;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
}

location / {
root /usr/share/nginx/html;
index index.html index.htm;
}

location = /50x.html {
root /usr/share/nginx/html;
}

error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
}

一般来说,以上的写法是没有问题的,proxy_set_header 也把一些 Header 进行设置,转发到后端服务器。 如果你这么写,打包 Docker 之后,测试没有遇到问题,那就完事了。

问题

但我遇到了一个奇怪的问题,某个接口在请求的时候,状态码还是 200,但其返回值总是为空,即 Response Data 的内容完全为空。 但是服务器端看 Log 确实有正常返回 Response,使用 Vue 的 devServer 也是正常的,使用 Postman 来请求也是正常的,但是经过 Nginx 这么一反向代理就不行了,什么 Response 都接收不到。 部署到 Prod 环境之后,浏览器上面可以得到这么个错误:

1
ERR_INCOMPLETE_CHUNKED_ENCODING

image-20191207042932549 最后经排查,发现后端接口使用时设定了 Transfer-Encoding: chunked 响应头:

1
Transfer-Encoding: chunked

这是啥?这时候就需要引出 Keep-Alive 的相关问题了。

什么是 Keep-Alive?

我们知道 HTTP 协议采用「请求-应答」模式,当使用普通模式,即非 Keep-Alive 模式时,每个请求/应答客户和服务器都要新建一个连接,完成之后立即断开连接(HTTP 协议为无连接的协议)。当使用 Keep-Alive 模式(又称持久连接、连接重用)时,Keep-Alive 功能使客户端到服务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive 功能避免了建立或者重新建立连接。

  • HTTP 1.0 中默认是关闭 Keep-Alive 的,需要在 HTTP 头加入Connection: Keep-Alive,才能启用 Keep-Alive
  • HTTP 1.1 中默认启用 Keep-Alive,如果请求头中加入 Connection: close,Keep-Alive 才关闭。

目前大部分浏览器都是用 HTTP 1.1 协议,也就是说默认都会发起 Keep-Alive 的连接请求了,所以是否能完成一个完整的 Keep-Alive 连接就看服务器设置情况。 启用 Keep-Alive 模式肯定更高效,性能更高。因为避免了建立/释放连接的开销。

Keep-Alive 模式下如何传输数据

Keep-Alive 模式,客户端如何判断请求所得到的响应数据已经接收完成呢?或者说如何知道服务器已经发生完了数据? 我们已经知道了,Keep-Alive 模式发送完数据,HTTP 服务器不会自动断开连接,所有不能再使用返回 EOF(-1)来判断。 那么怎么判断呢?一个是使用 Content-Length ,一个是使用 Transfer-Encoding。

Content-Length

顾名思义,Conent-Length 表示实体内容长度,客户端(服务器)可以根据这个值来判断数据是否接收完成。 由于 Content-Length 字段必须真实反映实体长度,但实际应用中,有些时候实体长度并没那么好获得,例如实体来自于网络文件,或者由动态语言生成。这时候要想准确获取长度,只能开一个足够大的 buffer,等内容全部生成好再计算。但这样做一方面需要更大的内存开销,另一方面也会让客户端等更久。 我们在做 WEB 性能优化时,有一个重要的指标叫 TTFB(Time To First Byte),它代表的是从客户端发出请求到收到响应的第一个字节所花费的时间。大部分浏览器自带的 Network 面板都可以看到这个指标,越短的 TTFB 意味着用户可以越早看到页面内容,体验越好。可想而知,服务端为了计算响应实体长度而缓存所有内容,跟更短的 TTFB 理念背道而驰。但在 HTTP 报文中,实体一定要在头部之后,顺序不能颠倒,为此我们需要一个新的机制:不依赖头部的长度信息,也能知道实体的边界。 但是如果消息中没有 Conent-Length,那该如何来判断呢?又在什么情况下会没有 Conent-Length 呢?

Transfer-Encoding

当客户端向服务器请求一个静态页面或者一张图片时,服务器可以很清楚地知道内容大小,然后通过 Content-length 消息首部字段告诉客户端需要接收多少数据。但是如果是动态页面等时,服务器是不可能预先知道内容大小,这时就可以使用 分块编码模式来传输数据了。即如果要一边产生数据,一边发给客户端,服务器就需要在请求头中使用Transfer-Encoding: chunked 这样的方式来代替 Content-Length,这就是分块编码。 分块编码相当简单,在头部加入 Transfer-Encoding: chunked 之后,就代表这个报文采用了分块编码。这时,报文中的实体需要改为用一系列分块来传输。每个分块包含十六进制的长度值和数据,长度值独占一行,长度不包括它结尾的 CRLF(rn),也不包括分块数据结尾的 CRLF。最后一个分块长度值必须为 0,对应的分块数据没有内容,表示实体结束。

回归问题

那么我说了这么一大通有什么用呢? OK,在我遇到的业务场景中,我发现服务器的响应头中就包含了Transfer-Encoding: chunked 这个字段。 而这个字段,在 HTTP 1.0 是不被支持的。 而 Nginx 的反向代理,默认用的就是 HTTP 1.0,那就导致了数据无法获取的问题,可以参考 Nginx 的官方文档说明:http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass。 原文中:

1
2
3
Syntax: proxy_http_version 1.0 | 1.1;
Default: proxy_http_version 1.0;
By default, version 1.0 is used. Version 1.1 is recommended for use with keepalive connections and NTLM authentication.

所以,我们如果要解决这个问题,只需要设置一下 HTTP 版本为 1.1 就好了: 修改 nginx.conf 文件如下:

1
2
3
4
5
6
7
location /api/ {
proxy_pass http://domain.com/api/;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
}

这里就增加了一行:

1
proxy_http_version 1.1;

这样再测试,反向代理就会支持 Transfer-Encoding: chunked 模式了,这也就呼应了之前在浏览器中遇到的 ERR_INCOMPLETE_CHUNKED_ENCODING 错误。 自此,问题完美解决。

复盘记录

一开始本来只想简单一记录就了事的,但一边写,发现某个地方还可以展开写得更详细。 所以干脆最后我对这个问题进行了详细的复盘和记录。在写本文之前,我其实只思考到了 Keep-Alive 和 HTTP 1.1 的问题,其实我对 Transfer-Encoding 这个并没有去深入思考。在边写边总结的过程中,为了把整个脉络讲明白,我又查询了一些 Transfer-Encoding 和 Nginx 的官方文档,对这块的了解变得更加深入,相当于我在整个记录的过程中,又对整个流程梳理了一遍,同时又有额外的收获。 所以,遇到问题,深入去思考、总结和复盘,是很有帮助的,这会让我们对问题的看法和理解更加透彻。 怎么说呢?在开发过程中,难免会遇到一些奇奇怪怪的 Bug,但这其实只是技术问题,总会解决的。 但怎样在开发过程中,不断提高自己的技术能力,我觉得需要从每一个细节出发,去思考一些事情的来龙去脉。思考得越多,我们对整个事件的把握也会越清晰,以后如果再遇到类似的或者关联的事情,就会迎刃而解了。 平时我们可能很多情况下都在写业务代码,可能比较枯燥,感觉对技术没有实质性的提升,但如果我们能从中提炼出一些核心的问题或解决方案,这才是能真正提高技术的时候,这才是最有价值的。

参考文章

本文部分内容改写或摘自下列内容。

  • HTTP Keep-Alive模式:https://www.cnblogs.com/skynet/archive/2010/12/11/1903347.html
  • Nginx proxy_set_header 理解:https://www.jianshu.com/p/cc5167032525
  • 使用 Docker 打造超溜的前端环境:https://github.com/axetroy/blog/issues/178
  • HTTP 协议中的 Transfer-Encoding:https://imququ.com/post/transfer-encoding-header-in-http.html

技术杂谈

阿里云作为国内最大的云服务商家,个人与企业上云都纷纷首选阿里云。但是在价格方面比整个市场有些许昂贵,让不少用户却而止步。因此星速云小编呕心沥血整理阿里云最新优惠折扣【汇总篇】,让大家不用花时间到处寻找优惠信息,帮助站长、开发者和企业们上云购节省项目开支。


最全:阿里云最新优惠获取教程【长期有效】


①:阿里云代金券2000元红包

阿里云代金券领取很简单,点击下面链接进行领取。 阿里云代金券领取和使用步骤教程 阿里云代金券领取地址:点击领取2000元代金券礼包 点击“立即领取”按钮就可以一键领取到所有满减代金券,最高2000元。别忘记通过购物车一键批量购买哟!

②:阿里云9折优惠码

新用户还可以使用手机扫码领取一个阿里云9折折扣码叠加上述阿里云代金券使用。该9折码只能通过阿里云手机客户端扫描领取,PC端无法领取,(限ECS首购并且优惠高于7折才可以使用,比如优惠已经为5折,则该折扣码无效) 阿里云代金券 注明:阿里云9折优惠码与阿里云2000元红包可叠加优惠折扣。


阿里云双12期间(2019.12.3-2019.12.31)最新优惠活动


阿里云双12优惠活动终于开启了,新用户1折甩卖,老用户五折,还可以领取2000元红包,优惠力度不亚于双11优惠活动哟!还不赶紧上云呢?错过双11优惠活动,那么双12不容错过了! 阿里云双12活动

什么?您还不知道云服务器用途

不管是做web网站、APP程序后端部署、应用程序后端、小程序后端等,还是打算创业的小伙伴,或者传统IDC自建机房的企业,上云已成为趋势。云服务器更便捷省心、节约IT运维的成本。

新用户1折优惠售卖:

实例规格

配置

带宽

时长

价格

官网购买

ECS突发性能型t5

1核2G40G高效云盘

1M

1年

89.00元

立即抢购

ECS突发性能型t5

1核2G40G高效云盘

1M

3年

229.00元

ECS共享型n4

2核4G40G高效云盘

3M

2年

469.00元

ECS突发性能t5

2核4G40G高效云盘

5M

3年

899.00元

ECS突发性能t5

2核4G40G高效云盘

3M

3年

639.00元

ECS共享型n4

2核4G40G高效云盘

3M

3年

799.00元

ECS共享通用型mn4

2核8G40G高效云盘

5M

3年

1399.00元

ECS突发性能t5(香港)

1核1G40G高效云盘

1M

1年

119.00元

ECS网络增强型sn1ne

4核8G40G高效云盘

5M

3年

5621.00元

8核16G40G高效云盘

8M

3年

12209.00元


注明:突发性t5实例,别看到价格比较便宜就直接购买,里面很多套路,购买页面有提示:限制20%性能基线。释义:依靠CPU 积分来提升 CPU 性能,满足业务需求。当实例实际工作性能高于基准 CPU 计算性能时,会把服务器 CPU 的性能限制在 20%以下,如果这时20%CPU性能满足不了业务需求,云服务器CPU会跑满100%,到那时候你以为是被某大佬攻击了,很有可能是你突发性t5实例CPU 积分消耗完了。笔者建议:如果用户业务对 CPU 要求高的,可以直接略过,选择t5实例(无限制CPU性能)、n4共享型、通用型mn4。以下笔者建议爆款:

个人博客与企业微服务首选

阿里云双12云服务器爆款

老用户五折优惠甩卖:

实例规格

CPU/内存/云盘

带宽

时长

价格

老用户优惠购买

云服务器计算型ic5

8核8G40G高效云盘

1M

1年

4433.94元

立即抢购

计算网络增强型sn1ne

8核16G40G高效云盘

1M

1年

3751.20元

通用网络增强型sn2ne

8核32G40G高效云盘

1M

1年

5353.20元

内存网络增强型se1ne

8核64G40G高效云盘

1M

1年

6793.20元

注明:本文为星速云原创版权所有,禁止转载,一经发现将追究版权责任!

Python

13.10 Scrapy 通用爬虫

通过 Scrapy,我们可以轻松地完成一个站点爬虫的编写。但如果抓取的站点量非常大,比如爬取各大媒体的新闻信息,多个 Spider 则可能包含很多重复代码。 如果我们将各个站点的 Spider 的公共部分保留下来,不同的部分提取出来作为单独的配置,如爬取规则、页面解析方式等抽离出来做成一个配置文件,那么我们在新增一个爬虫的时候,只需要实现这些网站的爬取规则和提取规则即可。 本节我们就来探究一下 Scrapy 通用爬虫的实现方法。

1. CrawlSpider

在实现通用爬虫之前我们需要先了解一下 CrawlSpider,其官方文档链接为:http://scrapy.readthedocs.io/en/latest/topics/spiders.html#crawlspider。 CrawlSpider 是 Scrapy 提供的一个通用 Spider。在 Spider 里,我们可以指定一些爬取规则来实现页面的提取,这些爬取规则由一个专门的数据结构 Rule 表示。Rule 里包含提取和跟进页面的配置,Spider 会根据 Rule 来确定当前页面中的哪些链接需要继续爬取、哪些页面的爬取结果需要用哪个方法解析等。 CrawlSpider 继承自 Spider 类。除了 Spider 类的所有方法和属性,它还提供了一个非常重要的属性和方法。

  • rules,它是爬取规则属性,是包含一个或多个 Rule 对象的列表。每个 Rule 对爬取网站的动作都做了定义,CrawlSpider 会读取 rules 的每一个 Rule 并进行解析。
  • parse_start_url(),它是一个可重写的方法。当 start_urls 里对应的 Request 得到 Response 时,该方法被调用,它会分析 Response 并必须返回 Item 对象或者 Request 对象。

这里最重要的内容莫过于 Rule 的定义了,它的定义和参数如下所示:

1
class scrapy.contrib.spiders.Rule(link_extractor, callback=None, cb_kwargs=None, follow=None, process_links=None, process_request=None)

下面对其参数依次说明:

  • link_extractor,是一个 Link Extractor 对象。通过它,Spider 可以知道从爬取的页面中提取哪些链接。提取出的链接会自动生成 Request。它又是一个数据结构,一般常用 LxmlLinkExtractor 对象作为参数,其定义和参数如下所示:
1
class scrapy.linkextractors.lxmlhtml.LxmlLinkExtractor(allow=(), deny=(), allow_domains=(), deny_domains=(), deny_extensions=None, restrict_xpaths=(), restrict_css=(), tags=('a', 'area'), attrs=('href',), canonicalize=False, unique=True, process_value=None, strip=True)

allow 是一个正则表达式或正则表达式列表,它定义了从当前页面提取出的链接哪些是符合要求的,只有符合要求的链接才会被跟进。deny 则相反。allow_domains 定义了符合要求的域名,只有此域名的链接才会被跟进生成新的 Request,它相当于域名白名单。deny_domains 则相反,相当于域名黑名单。restrict_xpaths 定义了从当前页面中 XPath 匹配的区域提取链接,其值是 XPath 表达式或 XPath 表达式列表。restrict_css 定义了从当前页面中 CSS 选择器匹配的区域提取链接,其值是 CSS 选择器或 CSS 选择器列表。还有一些其他参数代表了提取链接的标签、是否去重、链接的处理等内容,使用的频率不高。可以参考文档的参数说明:http://scrapy.readthedocs.io/en/latest/topics/link-extractors.html#module-scrapy.linkextractors.lxmlhtml

  • callback,即回调函数,和之前定义 Request 的 callback 有相同的意义。每次从 link_extractor 中获取到链接时,该函数将会调用。该回调函数接收一个 response 作为其第一个参数,并返回一个包含 Item 或 Request 对象的列表。注意,避免使用 parse() 作为回调函数。由于 CrawlSpider 使用 parse() 方法来实现其逻辑,如果 parse() 方法覆盖了,CrawlSpider 将会运行失败。
  • cb_kwargs,字典,它包含传递给回调函数的参数。
  • follow,布尔值,即 True 或 False,它指定根据该规则从 response 提取的链接是否需要跟进。如果 callback 参数为 None,follow 默认设置为 True,否则默认为 False。
  • process_links,指定处理函数,从 link_extractor 中获取到链接列表时,该函数将会调用,它主要用于过滤。
  • process_request,同样是指定处理函数,根据该 Rule 提取到每个 Request 时,该函数都会调用,对 Request 进行处理。该函数必须返回 Request 或者 None。

以上内容便是 CrawlSpider 中的核心 Rule 的基本用法。但这些内容可能还不足以完成一个 CrawlSpider 爬虫。下面我们利用 CrawlSpider 实现新闻网站的爬取实例,来更好地理解 Rule 的用法。

2. Item Loader

我们了解了利用 CrawlSpider 的 Rule 来定义页面的爬取逻辑,这是可配置化的一部分内容。但是,Rule 并没有对 Item 的提取方式做规则定义。对于 Item 的提取,我们需要借助另一个模块 Item Loader 来实现。 Item Loader 提供一种便捷的机制来帮助我们方便地提取 Item。它提供的一系列 API 可以分析原始数据对 Item 进行赋值。Item 提供的是保存抓取数据的容器,而 Item Loader 提供的是填充容器的机制。有了它,数据的提取会变得更加规则化。 Item Loader 的 API 如下所示:

1
class scrapy.loader.ItemLoader([item, selector, response,] **kwargs)

Item Loader 的 API 返回一个新的 Item Loader 来填充给定的 Item。如果没有给出 Item,则使用 default_item_class 中的类自动实例化。另外,它传入 selector 和 response 参数来使用选择器或响应参数实例化。 下面将依次说明 Item Loader 的 API 参数。

  • item,Item 对象,可以调用 add_xpath()、add_css() 或 add_value() 等方法来填充 Item 对象。
  • selector,Selector 对象,用来提取填充数据的选择器。
  • response,Response 对象,用于使用构造选择器的 Response。

一个比较典型的 Item Loader 实例如下:

1
2
3
4
5
6
7
8
9
10
11
from scrapy.loader import ItemLoader
from project.items import Product

def parse(self, response):
loader = ItemLoader(item=Product(), response=response)
loader.add_xpath('name', '//div[@class="product_name"]')
loader.add_xpath('name', '//div[@class="product_title"]')
loader.add_xpath('price', '//p[@id="price"]')
loader.add_css('stock', 'p#stock]')
loader.add_value('last_updated', 'today')
return loader.load_item()

这里首先声明一个 Product Item,用该 Item 和 Response 对象实例化 ItemLoader,调用 add_xpath() 方法把来自两个不同位置的数据提取出来,分配给 name 属性,再用 add_xpath()、add_css()、add_value() 等方法对不同属性依次赋值,最后调用 load_item() 方法实现 Item 的解析。这种方式比较规则化,我们可以把一些参数和规则单独提取出来做成配置文件或存到数据库,即可实现可配置化。 另外,Item Loader 每个字段中都包含了一个 Input Processor(输入处理器)和一个 Output Processor(输出处理器)。Input Processor 收到数据时立刻提取数据,Input Processor 的结果被收集起来并且保存在 ItemLoader 内,但是不分配给 Item。收集到所有的数据后,load_item() 方法被调用来填充再生成 Item 对象。在调用时会先调用 Output Processor 来处理之前收集到的数据,然后再存入 Item 中,这样就生成了 Item。 下面将介绍一些内置的 Processor。

Identity

Identity 是最简单的 Processor,不进行任何处理,直接返回原来的数据。

TakeFirst

TakeFirst 返回列表的第一个非空值,类似 extract_first() 的功能,常用作 Output Processor,如下所示:

1
2
3
from scrapy.loader.processors import TakeFirst
processor = TakeFirst()
print(processor(['', 1, 2, 3]))

输出结果如下所示:

1
1

经过此 Processor 处理后的结果返回了第一个不为空的值。

Join

Join 方法相当于字符串的 join() 方法,可以把列表拼合成字符串,字符串默认使用空格分隔,如下所示:

1
2
3
from scrapy.loader.processors import Join
processor = Join()
print(processor(['one', 'two', 'three']))

输出结果如下所示:

1
one two three

它也可以通过参数更改默认的分隔符,例如改成逗号:

1
2
3
from scrapy.loader.processors import Join
processor = Join(',')
print(processor(['one', 'two', 'three']))

运行结果如下所示:

1
one,two,three

Compose

Compose 是用给定的多个函数的组合而构造的 Processor,每个输入值被传递到第一个函数,其输出再传递到第二个函数,依次类推,直到最后一个函数返回整个处理器的输出,如下所示:

1
2
3
from scrapy.loader.processors import Compose
processor = Compose(str.upper, lambda s: s.strip())
print(processor(' hello world'))

运行结果如下所示:

1
HELLO WORLD

在这里我们构造了一个 Compose Processor,传入一个开头带有空格的字符串。Compose Processor 的参数有两个:第一个是 str.upper,它可以将字母全部转为大写;第二个是一个匿名函数,它调用 strip() 方法去除头尾空白字符。Compose 会顺次调用两个参数,最后返回结果的字符串全部转化为大写并且去除了开头的空格。

MapCompose

与 Compose 类似,MapCompose 可以迭代处理一个列表输入值,如下所示:

1
2
3
from scrapy.loader.processors import MapCompose
processor = MapCompose(str.upper, lambda s: s.strip())
print(processor(['Hello', 'World', 'Python']))

运行结果如下所示:

1
['HELLO', 'WORLD', 'PYTHON']

被处理的内容是一个可迭代对象,MapCompose 会将该对象遍历然后依次处理。

SelectJmes

SelectJmes 可以查询 JSON,传入 Key,返回查询所得的 Value。不过需要先安装 jmespath 库才可以使用它,命令如下所示:

1
pip3 install jmespath

安装好 jmespath 之后,便可以使用这个 Processor 了,如下所示:

1
2
3
4
from scrapy.loader.processors import SelectJmes
proc = SelectJmes('foo')
processor = SelectJmes('foo')
print(processor({'foo': 'bar'}))

运行结果:

1
bar

以上内容便是一些常用的 Processor,在本节的实例中我们会使用 Processor 来进行数据的处理。 接下来,我们用一个实例来了解 Item Loader 的用法。

3. 本节目标

我们以中华网科技类新闻为例,来了解 CrawlSpider 和 Item Loader 的用法,再提取其可配置信息实现可配置化。官网链接为:http://tech.china.com/。我们需要爬取它的科技类新闻内容,链接为:http://tech.china.com/articles/,页面如图 13-19 所示。 我们要抓取新闻列表中的所有分页的新闻详情,包括标题、正文、时间、来源等信息。 图 13-19 爬取站点

4. 新建项目

首先新建一个 Scrapy 项目,名为 scrapyuniversal,如下所示:

1
scrapy startproject scrapyuniversal

创建一个 CrawlSpider,需要先制定一个模板。我们可以先看看有哪些可用模板,命令如下所示:

1
scrapy genspider -l

运行结果如下所示:

1
2
3
4
5
Available templates:
basic
crawl
csvfeed
xmlfeed

之前创建 Spider 的时候,我们默认使用了第一个模板 basic。这次要创建 CrawlSpider,就需要使用第二个模板 crawl,创建命令如下所示:

1
scrapy genspider -t crawl china tech.china.com

运行之后便会生成一个 CrawlSpider,其内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule

class ChinaSpider(CrawlSpider):
name = 'china'
allowed_domains = ['tech.china.com']
start_urls = ['http://tech.china.com/']

rules = (Rule(LinkExtractor(allow=r'Items/'), callback='parse_item', follow=True),
)

def parse_item(self, response):
i = {}
#i['domain_id'] = response.xpath('//input[@id="sid"]/@value').extract()
#i['name'] = response.xpath('//div[@id="name"]').extract()
#i['description'] = response.xpath('//div[@id="description"]').extract()
return i

这次生成的 Spider 内容多了一个 rules 属性的定义。Rule 的第一个参数是 LinkExtractor,就是上文所说的 LxmlLinkExtractor,只是名称不同。同时,默认的回调函数也不再是 parse,而是 parse_item。

5. 定义 Rule

要实现新闻的爬取,我们需要做的就是定义好 Rule,然后实现解析函数。下面我们就来一步步实现这个过程。 首先将 start_urls 修改为起始链接,代码如下所示:

1
start_urls = ['http://tech.china.com/articles/']

之后,Spider 爬取 start_urls 里面的每一个链接。所以这里第一个爬取的页面就是我们刚才所定义的链接。得到 Response 之后,Spider 就会根据每一个 Rule 来提取这个页面内的超链接,去生成进一步的 Request。接下来,我们就需要定义 Rule 来指定提取哪些链接。 当前页面如图 13-20 所示: 图 13-20 页面内容 这是新闻的列表页,下一步自然就是将列表中的每条新闻详情的链接提取出来。这里直接指定这些链接所在区域即可。查看源代码,所有链接都在 ID 为 left_side 的节点内,具体来说是它内部的 class 为 con_item 的节点,如图 13-21 所示。 图 13-21 列表源码 此处我们可以用 LinkExtractor 的 restrict_xpaths 属性来指定,之后 Spider 就会从这个区域提取所有的超链接并生成 Request。但是,每篇文章的导航中可能还有一些其他的超链接标签,我们只想把需要的新闻链接提取出来。真正的新闻链接路径都是以 article 开头的,我们用一个正则表达式将其匹配出来再赋值给 allow 参数即可。另外,这些链接对应的页面其实就是对应的新闻详情页,而我们需要解析的就是新闻的详情信息,所以此处还需要指定一个回调函数 callback。 到现在我们就可以构造出一个 Rule 了,代码如下所示:

1
Rule(LinkExtractor(allow='article/.*.html', restrict_xpaths='//div[@id="left_side"]//div[@class="con_item"]'), callback='parse_item')

接下来,我们还要让当前页面实现分页功能,所以还需要提取下一页的链接。分析网页源码之后可以发现下一页链接是在 ID 为 pageStyle 的节点内,如图 13-22 所示。 图 13-22 分页源码 但是,下一页节点和其他分页链接区分度不高,要取出此链接我们可以直接用 XPath 的文本匹配方式,所以这里我们直接用 LinkExtractor 的 restrict_xpaths 属性来指定提取的链接即可。另外,我们不需要像新闻详情页一样去提取此分页链接对应的页面详情信息,也就是不需要生成 Item,所以不需要加 callback 参数。另外这下一页的页面如果请求成功了就需要继续像上述情况一样分析,所以它还需要加一个 follow 参数为 True,代表继续跟进匹配分析。其实,follow 参数也可以不加,因为当 callback 为空的时候,follow 默认为 True。此处 Rule 定义为如下所示:

1
Rule(LinkExtractor(restrict_xpaths='//div[@id="pageStyle"]//a[contains(., "下一页")]'))

所以现在 rules 就变成了:

1
2
3
rules = (Rule(LinkExtractor(allow='article/.*.html', restrict_xpaths='//div[@id="left_side"]//div[@class="con_item"]'), callback='parse_item'),
Rule(LinkExtractor(restrict_xpaths='//div[@id="pageStyle"]//a[contains(., "下一页")]'))
)

接着我们运行一下代码,命令如下:

1
scrapy crawl china

现在已经实现页面的翻页和详情页的抓取了,我们仅仅通过定义了两个 Rule 即实现了这样的功能,运行效果如图 13-23 所示。 图 13-23 运行效果

6. 解析页面

接下来我们需要做的就是解析页面内容了,将标题、发布时间、正文、来源提取出来即可。首先定义一个 Item,如下所示:

1
2
3
4
5
6
7
8
9
from scrapy import Field, Item

class NewsItem(Item):
title = Field()
url = Field()
text = Field()
datetime = Field()
source = Field()
website = Field()

这里的字段分别指新闻标题、链接、正文、发布时间、来源、站点名称,其中站点名称直接赋值为中华网。因为既然是通用爬虫,肯定还有很多爬虫也来爬取同样结构的其他站点的新闻内容,所以需要一个字段来区分一下站点名称。 详情页的预览图如图 13-24 所示。 图 13-24 详情页面 如果像之前一样提取内容,就直接调用 response 变量的 xpath()、css() 等方法即可。这里 parse_item() 方法的实现如下所示:

1
2
3
4
5
6
7
8
9
def parse_item(self, response):
item = NewsItem()
item['title'] = response.xpath('//h1[@id="chan_newsTitle"]/text()').extract_first()
item['url'] = response.url
item['text'] = ''.join(response.xpath('//div[@id="chan_newsDetail"]//text()').extract()).strip()
item['datetime'] = response.xpath('//div[@id="chan_newsInfo"]/text()').re_first('(d+-d+-d+sd+:d+:d+)')
item['source'] = response.xpath('//div[@id="chan_newsInfo"]/text()').re_first(' 来源:(.*)').strip()
item['website'] = ' 中华网 '
yield item

这样我们就把每条新闻的信息提取形成了一个 NewsItem 对象。 这时实际上我们就已经完成了 Item 的提取。再运行一下 Spider,如下所示:

1
scrapy crawl china

输出内容如图 13-25 所示: 图 13-25 输出内容 现在我们就可以成功将每条新闻的信息提取出来。 不过我们发现这种提取方式非常不规整。下面我们再用 Item Loader,通过 add_xpath()、add_css()、add_value() 等方式实现配置化提取。我们可以改写 parse_item(),如下所示:

1
2
3
4
5
6
7
8
9
def parse_item(self, response):
loader = ChinaLoader(item=NewsItem(), response=response)
loader.add_xpath('title', '//h1[@id="chan_newsTitle"]/text()')
loader.add_value('url', response.url)
loader.add_xpath('text', '//div[@id="chan_newsDetail"]//text()')
loader.add_xpath('datetime', '//div[@id="chan_newsInfo"]/text()', re='(d+-d+-d+sd+:d+:d+)')
loader.add_xpath('source', '//div[@id="chan_newsInfo"]/text()', re=' 来源:(.*)')
loader.add_value('website', ' 中华网 ')
yield loader.load_item()

这里我们定义了一个 ItemLoader 的子类,名为 ChinaLoader,其实现如下所示:

1
2
3
4
5
6
7
8
9
from scrapy.loader import ItemLoader
from scrapy.loader.processors import TakeFirst, Join, Compose

class NewsLoader(ItemLoader):
default_output_processor = TakeFirst()

class ChinaLoader(NewsLoader):
text_out = Compose(Join(), lambda s: s.strip())
source_out = Compose(Join(), lambda s: s.strip())

ChinaLoader 继承了 NewsLoader 类,其内定义了一个通用的 Out Processor 为 TakeFirst,这相当于之前所定义的 extract_first() 方法的功能。我们在 ChinaLoader 中定义了 text_out 和 source_out 字段。这里使用了一个 Compose Processor,它有两个参数:第一个参数 Join 也是一个 Processor,它可以把列表拼合成一个字符串;第二个参数是一个匿名函数,可以将字符串的头尾空白字符去掉。经过这一系列处理之后,我们就将列表形式的提取结果转化为去除头尾空白字符的字符串。 代码重新运行,提取效果是完全一样的。 至此,我们已经实现了爬虫的半通用化配置。

7. 通用配置抽取

为什么现在只做到了半通用化?如果我们需要扩展其他站点,仍然需要创建一个新的 CrawlSpider,定义这个站点的 Rule,单独实现 parse_item() 方法。还有很多代码是重复的,如 CrawlSpider 的变量、方法名几乎都是一样的。那么我们可不可以把多个类似的几个爬虫的代码共用,把完全不相同的地方抽离出来,做成可配置文件呢? 当然可以。那我们可以抽离出哪些部分?所有的变量都可以抽取,如 name、allowed_domains、start_urls、rules 等。这些变量在 CrawlSpider 初始化的时候赋值即可。我们就可以新建一个通用的 Spider 来实现这个功能,命令如下所示:

1
scrapy genspider -t crawl universal universal

这个全新的 Spider 名为 universal。接下来,我们将刚才所写的 Spider 内的属性抽离出来配置成一个 JSON,命名为 china.json,放到 configs 文件夹内,和 spiders 文件夹并列,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
{
"spider": "universal",
"website": "中华网科技",
"type": "新闻",
"index": "http://tech.china.com/",
"settings": {"USER_AGENT": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36"
},
"start_urls": ["http://tech.china.com/articles/"],
"allowed_domains": ["tech.china.com"],
"rules": "china"
}

第一个字段 spider 即 Spider 的名称,在这里是 universal。后面是站点的描述,比如站点名称、类型、首页等。随后的 settings 是该 Spider 特有的 settings 配置,如果要覆盖全局项目,settings.py 内的配置可以单独为其配置。随后是 Spider 的一些属性,如 start_urls、allowed_domains、rules 等。rules 也可以单独定义成一个 rules.py 文件,做成配置文件,实现 Rule 的分离,如下所示:

1
2
3
4
5
6
7
8
9
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import Rule

rules = {
'china': (Rule(LinkExtractor(allow='article/.*.html', restrict_xpaths='//div[@id="left_side"]//div[@class="con_item"]'),
callback='parse_item'),
Rule(LinkExtractor(restrict_xpaths='//div[@id="pageStyle"]//a[contains(., "下一页")]'))
)
}

这样我们将基本的配置抽取出来。如果要启动爬虫,只需要从该配置文件中读取然后动态加载到 Spider 中即可。所以我们需要定义一个读取该 JSON 文件的方法,如下所示:

1
2
3
4
5
6
from os.path import realpath, dirname
import json
def get_config(name):
path = dirname(realpath(__file__)) + '/configs/' + name + '.json'
with open(path, 'r', encoding='utf-8') as f:
return json.loads(f.read())

定义了 get_config() 方法之后,我们只需要向其传入 JSON 配置文件的名称即可获取此 JSON 配置信息。随后我们定义入口文件 run.py,把它放在项目根目录下,它的作用是启动 Spider,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import sys
from scrapy.utils.project import get_project_settings
from scrapyuniversal.spiders.universal import UniversalSpider
from scrapyuniversal.utils import get_config
from scrapy.crawler import CrawlerProcess

def run():
name = sys.argv[1]
custom_settings = get_config(name)
# 爬取使用的 Spider 名称
spider = custom_settings.get('spider', 'universal')
project_settings = get_project_settings()
settings = dict(project_settings.copy())
# 合并配置
settings.update(custom_settings.get('settings'))
process = CrawlerProcess(settings)
# 启动爬虫
process.crawl(spider, **{'name': name})
process.start()

if __name__ == '__main__':
run()

运行入口为 run()。首先获取命令行的参数并赋值为 name,name 就是 JSON 文件的名称,其实就是要爬取的目标网站的名称。我们首先利用 get_config() 方法,传入该名称读取刚才定义的配置文件。获取爬取使用的 spider 的名称、配置文件中的 settings 配置,然后将获取到的 settings 配置和项目全局的 settings 配置做了合并。新建一个 CrawlerProcess,传入爬取使用的配置。调用 crawl() 和 start() 方法即可启动爬取。 在 universal 中,我们新建一个init() 方法,进行初始化配置,实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from scrapyuniversal.utils import get_config
from scrapyuniversal.rules import rules

class UniversalSpider(CrawlSpider):
name = 'universal'
def __init__(self, name, *args, **kwargs):
config = get_config(name)
self.config = config
self.rules = rules.get(config.get('rules'))
self.start_urls = config.get('start_urls')
self.allowed_domains = config.get('allowed_domains')
super(UniversalSpider, self).__init__(*args, **kwargs)

def parse_item(self, response):
i = {}
return i

init() 方法中,start_urls、allowed_domains、rules 等属性被赋值。其中,rules 属性另外读取了 rules.py 的配置,这样就成功实现爬虫的基础配置。 接下来,执行如下命令运行爬虫:

1
python3 run.py china

程序会首先读取 JSON 配置文件,将配置中的一些属性赋值给 Spider,然后启动爬取。运行效果完全相同,运行结果如图 13-26 所示。 图 13-26 运行结果 现在我们已经对 Spider 的基础属性实现了可配置化。剩下的解析部分同样需要实现可配置化,原来的解析函数如下所示:

1
2
3
4
5
6
7
8
9
def parse_item(self, response):
loader = ChinaLoader(item=NewsItem(), response=response)
loader.add_xpath('title', '//h1[@id="chan_newsTitle"]/text()')
loader.add_value('url', response.url)
loader.add_xpath('text', '//div[@id="chan_newsDetail"]//text()')
loader.add_xpath('datetime', '//div[@id="chan_newsInfo"]/text()', re='(d+-d+-d+sd+:d+:d+)')
loader.add_xpath('source', '//div[@id="chan_newsInfo"]/text()', re=' 来源:(.*)')
loader.add_value('website', ' 中华网 ')
yield loader.load_item()

我们需要将这些配置也抽离出来。这里的变量主要有 Item Loader 类的选用、Item 类的选用、Item Loader 方法参数的定义,我们可以在 JSON 文件中添加如下 item 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
"item": {
"class": "NewsItem",
"loader": "ChinaLoader",
"attrs": {
"title": [
{
"method": "xpath",
"args": ["//h1[@id='chan_newsTitle']/text()"]
}
],
"url": [
{
"method": "attr",
"args": ["url"]
}
],
"text": [
{
"method": "xpath",
"args": ["//div[@id='chan_newsDetail']//text()"]
}
],
"datetime": [
{
"method": "xpath",
"args": ["//div[@id='chan_newsInfo']/text()"],
"re": "(\\d+-\\d+-\\d+\\s\\d+:\\d+:\\d+)"
}
],
"source": [
{
"method": "xpath",
"args": ["//div[@id='chan_newsInfo']/text()"],
"re": "来源:(.*)"
}
],
"website": [
{
"method": "value",
"args": ["中华网"]
}
]
}
}

这里定义了 class 和 loader 属性,它们分别代表 Item 和 Item Loader 所使用的类。定义了 attrs 属性来定义每个字段的提取规则,例如,title 定义的每一项都包含一个 method 属性,它代表使用的提取方法,如 xpath 即代表调用 Item Loader 的 add_xpath() 方法。args 即参数,就是 add_xpath() 的第二个参数,即 XPath 表达式。针对 datetime 字段,我们还用了一次正则提取,所以这里还可以定义一个 re 参数来传递提取时所使用的正则表达式。 我们还要将这些配置之后动态加载到 parse_item() 方法里。最后,最重要的就是实现 parse_item() 方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def parse_item(self, response):
item = self.config.get('item')
if item:
cls = eval(item.get('class'))()
loader = eval(item.get('loader'))(cls, response=response)
# 动态获取属性配置
for key, value in item.get('attrs').items():
for extractor in value:
if extractor.get('method') == 'xpath':
loader.add_xpath(key, *extractor.get('args'), **{'re': extractor.get('re')})
if extractor.get('method') == 'css':
loader.add_css(key, *extractor.get('args'), **{'re': extractor.get('re')})
if extractor.get('method') == 'value':
loader.add_value(key, *extractor.get('args'), **{'re': extractor.get('re')})
if extractor.get('method') == 'attr':
loader.add_value(key, getattr(response, *extractor.get('args')))
yield loader.load_item()

这里首先获取 Item 的配置信息,然后获取 class 的配置,将其初始化,初始化 Item Loader,遍历 Item 的各个属性依次进行提取。判断 method 字段,调用对应的处理方法进行处理。如 method 为 css,就调用 Item Loader 的 add_css() 方法进行提取。所有配置动态加载完毕之后,调用 load_item() 方法将 Item 提取出来。 重新运行程序,结果如图 13-27 所示。 图 13-27 运行结果 运行结果是完全相同的。 我们再回过头看一下 start_urls 的配置。这里 start_urls 只可以配置具体的链接。如果这些链接有 100 个、1000 个,我们总不能将所有的链接全部列出来吧?在某些情况下,start_urls 也需要动态配置。我们将 start_urls 分成两种,一种是直接配置 URL 列表,一种是调用方法生成,它们分别定义为 static 和 dynamic 类型。 本例中的 start_urls 很明显是 static 类型的,所以 start_urls 配置改写如下所示: ```json”start_urls”: {“type”:”static”,”value”: [“http://tech.china.com/articles/“] }

1
2
3
4
5
6
7
如果 start_urls 是动态生成的,我们可以调用方法传参数,如下所示:
```json
"start_urls": {
"type": "dynamic",
"method": "china",
"args": [5, 10]
}

这里 start_urls 定义为 dynamic 类型,指定方法为 urls_china(),然后传入参数 5 和 10,来生成第 5 到 10 页的链接。这样我们只需要实现该方法即可,统一新建一个 urls.py 文件,如下所示:

1
2
3
def china(start, end):
for page in range(start, end + 1):
yield 'http://tech.china.com/articles/index_' + str(page) + '.html'

其他站点可以自行配置。如某些链接需要用到时间戳,加密参数等,均可通过自定义方法实现。 接下来在 Spider 的 init() 方法中,start_urls 的配置改写如下所示:

1
2
3
4
5
6
7
8
from scrapyuniversal import urls

start_urls = config.get('start_urls')
if start_urls:
if start_urls.get('type') == 'static':
self.start_urls = start_urls.get('value')
elif start_urls.get('type') == 'dynamic':
self.start_urls = list(eval('urls.' + start_urls.get('method'))(*start_urls.get('args', [])))

这里通过判定 start_urls 的类型分别进行不同的处理,这样我们就可以实现 start_urls 的配置了。 至此,Spider 的设置、起始链接、属性、提取方法都已经实现了全部的可配置化。 综上所述,整个项目的配置包括如下内容。

  • spider,指定所使用的 Spider 的名称。
  • settings,可以专门为 Spider 定制配置信息,会覆盖项目级别的配置。
  • start_urls,指定爬虫爬取的起始链接。
  • allowed_domains,允许爬取的站点。
  • rules,站点的爬取规则。
  • item,数据的提取规则。

我们实现了 Scrapy 的通用爬虫,每个站点只需要修改 JSON 文件即可实现自由配置。

7. 本节代码

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

8. 结语

本节介绍了 Scrapy 通用爬虫的实现。我们将所有配置抽离出来,每增加一个爬虫,就只需要增加一个 JSON 文件配置。之后我们只需要维护这些配置文件即可。如果要更加方便的管理,可以将规则存入数据库,再对接可视化管理页面即可。

Python

13.9 Scrapy 对接 Splash

在上一节我们实现了 Scrapy 对接 Selenium 抓取淘宝商品的过程,这是一种抓取 JavaScript 动态渲染页面的方式。除了 Selenium,Splash 也可以实现同样的功能。本节我们来了解 Scrapy 对接 Splash 来进行页面抓取的方式。

1. 准备工作

请确保 Splash 已经正确安装并正常运行,同时安装好 Scrapy-Splash 库,如果没有安装可以参考第 1 章的安装说明。

2. 新建项目

首先新建一个项目,名为 scrapysplashtest,命令如下所示:

1
scrapy startproject scrapysplashtest

新建一个 Spider,命令如下所示:

1
scrapy genspider taobao www.taobao.com

3. 添加配置

可以参考 Scrapy-Splash 的配置说明进行一步步的配置,链接如下:https://github.com/scrapy-plugins/scrapy-splash#configuration。 修改 settings.py,配置 SPLASH_URL。在这里我们的 Splash 是在本地运行的,所以可以直接配置本地的地址:

1
SPLASH_URL = 'http://localhost:8050'

如果 Splash 是在远程服务器运行的,那此处就应该配置为远程的地址。例如运行在 IP 为 120.27.34.25 的服务器上,则此处应该配置为:

1
SPLASH_URL = 'http://120.27.34.25:8050'

还需要配置几个 Middleware,代码如下所示:

1
2
3
4
5
6
DOWNLOADER_MIDDLEWARES = {
'scrapy_splash.SplashCookiesMiddleware': 723,
'scrapy_splash.SplashMiddleware': 725,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}
SPIDER_MIDDLEWARES = {'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,}

这里配置了三个 Downloader Middleware 和一个 Spider Middleware,这是 Scrapy-Splash 的核心部分。我们不再需要像对接 Selenium 那样实现一个 Downloader Middleware,Scrapy-Splash 库都为我们准备好了,直接配置即可。 还需要配置一个去重的类 DUPEFILTER_CLASS,代码如下所示:

1
DUPEFILTER_CLASS = 'scrapy_splash.SplashAwareDupeFilter'

最后配置一个 Cache 存储 HTTPCACHE_STORAGE,代码如下所示:

1
HTTPCACHE_STORAGE = 'scrapy_splash.SplashAwareFSCacheStorage'

4. 新建请求

配置完成之后,我们就可以利用 Splash 来抓取页面了。我们可以直接生成一个 SplashRequest 对象并传递相应的参数,Scrapy 会将此请求转发给 Splash,Splash 对页面进行渲染加载,然后再将渲染结果传递回来。此时 Response 的内容就是渲染完成的页面结果了,最后交给 Spider 解析即可。 我们来看一个示例,如下所示:

1
2
3
4
5
6
7
8
9
10
11
yield SplashRequest(url, self.parse_result,
args={
# optional; parameters passed to Splash HTTP API
'wait': 0.5,
# 'url' is prefilled from request url
# 'http_method' is set to 'POST' for POST requests
# 'body' is set to request body for POST requests
},
endpoint='render.json', # optional; default is render.html
splash_url='<url>', # optional; overrides SPLASH_URL
)

在这里构造了一个 SplashRequest 对象,前两个参数依然是请求的 URL 和回调函数,另外还可以通过 args 传递一些渲染参数,例如等待时间 wait 等,还可以根据 endpoint 参数指定渲染接口,另外还有更多的参数可以参考文档的说明:https://github.com/scrapy-plugins/scrapy-splash#requests。 另外我们也可以生成 Request 对象,关于 Splash 的配置通过 meta 属性配置即可,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
yield scrapy.Request(url, self.parse_result, meta={
'splash': {
'args': {
# set rendering arguments here
'html': 1,
'png': 1,
# 'url' is prefilled from request url
# 'http_method' is set to 'POST' for POST requests
# 'body' is set to request body for POST requests
},
# optional parameters
'endpoint': 'render.json', # optional; default is render.json
'splash_url': '<url>', # optional; overrides SPLASH_URL
'slot_policy': scrapy_splash.SlotPolicy.PER_DOMAIN,
'splash_headers': {}, # optional; a dict with headers sent to Splash
'dont_process_response': True, # optional, default is False
'dont_send_headers': True, # optional, default is False
'magic_response': False, # optional, default is True
}
})

SplashRequest 对象通过 args 来配置和 Request 对象通过 meta 来配置,两种方式达到的效果是相同的。 本节我们要做的抓取是淘宝商品信息,涉及页面加载等待、模拟点击翻页等操作。我们可以首先定义一个 Lua 脚本,来实现页面加载、模拟点击翻页的功能,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function main(splash, args)
args = {
url="https://s.taobao.com/search?q=iPad",
wait=5,
page=5
}
splash.images_enabled = false
assert(splash:go(args.url))
assert(splash:wait(args.wait))
js = string.format("document.querySelector('#mainsrp-pager div.form> input').value=% d;document.querySelector('#mainsrp-pager div.form> span.btn.J_Submit').click()", args.page)
splash:evaljs(js)
assert(splash:wait(args.wait))
return splash:png()
end

我们定义了三个参数:请求的链接 url、等待时间 wait、分页页码 page。然后禁用图片加载,请求淘宝的商品列表页面,通过 evaljs() 方法调用 JavaScript 代码,实现页码填充和翻页点击,最后返回页面截图。我们将脚本放到 Splash 中运行,正常获取到页面截图,如图 13-15 所示。 图 13-15 页面截图 翻页操作也成功实现,如图 13-16 所示即为当前页码,和我们传入的页码 page 参数是相同的。 图 13-16 翻页结果 我们只需要在 Spider 里用 SplashRequest 对接 Lua 脚本就好了,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from scrapy import Spider
from urllib.parse import quote
from scrapysplashtest.items import ProductItem
from scrapy_splash import SplashRequest

script = """
function main(splash, args)
splash.images_enabled = false
assert(splash:go(args.url))
assert(splash:wait(args.wait))
js = string.format("document.querySelector('#mainsrp-pager div.form> input').value=% d;document.querySelector('#mainsrp-pager div.form> span.btn.J_Submit').click()", args.page)
splash:evaljs(js)
assert(splash:wait(args.wait))
return splash:html()
end
"""

class TaobaoSpider(Spider):
name = 'taobao'
allowed_domains = ['www.taobao.com']
base_url = 'https://s.taobao.com/search?q='

def start_requests(self):
for keyword in self.settings.get('KEYWORDS'):
for page in range(1, self.settings.get('MAX_PAGE') + 1):
url = self.base_url + quote(keyword)
yield SplashRequest(url, callback=self.parse, endpoint='execute', args={'lua_source': script, 'page': page, 'wait': 7})

我们把 Lua 脚本定义成长字符串,通过 SplashRequest 的 args 来传递参数,接口修改为 execute。另外,args 参数里还有一个 lua_source 字段用于指定 Lua 脚本内容。这样我们就成功构造了一个 SplashRequest,对接 Splash 的工作就完成了。 其他的配置不需要更改,Item、Item Pipeline 等设置与上节对接 Selenium 的方式相同,parse() 回调函数也是完全一致的。

5. 运行

接下来,我们通过如下命令运行爬虫:

1
scrapy crawl taobao

运行结果如图 13-17 所示。 图 13-17 运行结果 由于 Splash 和 Scrapy 都支持异步处理,我们可以看到同时会有多个抓取成功的结果。在 Selenium 的对接过程中,每个页面渲染下载是在 Downloader Middleware 里完成的,所以整个过程是阻塞式的。Scrapy 会等待这个过程完成后再继续处理和调度其他请求,这影响了爬取效率。因此使用 Splash 的爬取效率比 Selenium 高很多。 最后我们再看看 MongoDB 的结果,如图 13-18 所示。 图 13-18 存储结果 结果同样正常保存到了 MongoDB 中。

6. 本节代码

本节代码地址:https://github.com/Python3WebSpider/ScrapySplashTest

7. 结语

在 Scrapy 中,建议使用 Splash 处理 JavaScript 动态渲染的页面。这样不会破坏 Scrapy 中的异步处理过程,会大大提高爬取效率。而且 Splash 的安装和配置比较简单,通过 API 调用的方式实现了模块分离,大规模爬取的部署也更加方便。

Python

13.8 Scrapy 对接 Selenium

Scrapy 抓取页面的方式和 requests 库类似,都是直接模拟 HTTP 请求,而 Scrapy 也不能抓取 JavaScript 动态渲染的页面。在前文中抓取 JavaScript 渲染的页面有两种方式。一种是分析 Ajax 请求,找到其对应的接口抓取,Scrapy 同样可以用此种方式抓取。另一种是直接用 Selenium 或 Splash 模拟浏览器进行抓取,我们不需要关心页面后台发生的请求,也不需要分析渲染过程,只需要关心页面最终结果即可,可见即可爬。那么,如果 Scrapy 可以对接 Selenium,那 Scrapy 就可以处理任何网站的抓取了。

1. 本节目标

本节我们来看看 Scrapy 框架如何对接 Selenium,以 PhantomJS 进行演示。我们依然抓取淘宝商品信息,抓取逻辑和前文中用 Selenium 抓取淘宝商品完全相同。

2. 准备工作

请确保 PhantomJS 和 MongoDB 已经安装好并可以正常运行,安装好 Scrapy、Selenium、PyMongo 库,安装方式可以参考第 1 章的安装说明。

3. 新建项目

首先新建项目,名为 scrapyseleniumtest,命令如下所示:

1
scrapy startproject scrapyseleniumtest

新建一个 Spider,命令如下所示:

1
scrapy genspider taobao www.taobao.com

修改 ROBOTSTXT_OBEY 为 False,如下所示:

1
ROBOTSTXT_OBEY = False

4. 定义 Item

首先定义 Item 对象,名为 ProductItem,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
from scrapy import Item, Field

class ProductItem(Item):

collection = 'products'
image = Field()
price = Field()
deal = Field()
title = Field()
shop = Field()
location = Field()

这里我们定义了 6 个 Field,也就是 6 个字段,跟之前的案例完全相同。然后定义了一个 collection 属性,即此 Item 保存到 MongoDB 的 Collection 名称。 初步实现 Spider 的 start_requests() 方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from scrapy import Request, Spider
from urllib.parse import quote
from scrapyseleniumtest.items import ProductItem

class TaobaoSpider(Spider):
name = 'taobao'
allowed_domains = ['www.taobao.com']
base_url = 'https://s.taobao.com/search?q='

def start_requests(self):
for keyword in self.settings.get('KEYWORDS'):
for page in range(1, self.settings.get('MAX_PAGE') + 1):
url = self.base_url + quote(keyword)
yield Request(url=url, callback=self.parse, meta={'page': page}, dont_filter=True)

首先定义了一个 base_url,即商品列表的 URL,其后拼接一个搜索关键字就是该关键字在淘宝的搜索结果商品列表页面。 关键字用 KEYWORDS 标识,定义为一个列表。最大翻页页码用 MAX_PAGE 表示。它们统一定义在 setttings.py 里面,如下所示:

1
2
KEYWORDS = ['iPad']
MAX_PAGE = 100

在 start_requests() 方法里,我们首先遍历了关键字,遍历了分页页码,构造并生成 Request。由于每次搜索的 URL 是相同的,所以分页页码用 meta 参数来传递,同时设置 dont_filter 不去重。这样爬虫启动的时候,就会生成每个关键字对应的商品列表的每一页的请求了。

5. 对接 Selenium

接下来我们需要处理这些请求的抓取。这次我们对接 Selenium 进行抓取,采用 Downloader Middleware 来实现。在 Middleware 里面的 process_request() 方法里对每个抓取请求进行处理,启动浏览器并进行页面渲染,再将渲染后的结果构造一个 HtmlResponse 对象返回。代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from scrapy.http import HtmlResponse
from logging import getLogger

class SeleniumMiddleware():
def __init__(self, timeout=None, service_args=[]):
self.logger = getLogger(__name__)
self.timeout = timeout
self.browser = webdriver.PhantomJS(service_args=service_args)
self.browser.set_window_size(1400, 700)
self.browser.set_page_load_timeout(self.timeout)
self.wait = WebDriverWait(self.browser, self.timeout)

def __del__(self):
self.browser.close()

def process_request(self, request, spider):
"""
用 PhantomJS 抓取页面
:param request: Request 对象
:param spider: Spider 对象
:return: HtmlResponse
"""
self.logger.debug('PhantomJS is Starting')
page = request.meta.get('page', 1)
try:
self.browser.get(request.url)
if page > 1:
input = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '#mainsrp-pager div.form> input')))
submit = self.wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '#mainsrp-pager div.form> span.btn.J_Submit')))
input.clear()
input.send_keys(page)
submit.click()
self.wait.until(EC.text_to_be_present_in_element((By.CSS_SELECTOR, '#mainsrp-pager li.item.active> span'), str(page)))
self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '.m-itemlist .items .item')))
return HtmlResponse(url=request.url, body=self.browser.page_source, request=request, encoding='utf-8', status=200)
except TimeoutException:
return HtmlResponse(url=request.url, status=500, request=request)

@classmethod
def from_crawler(cls, crawler):
return cls(timeout=crawler.settings.get('SELENIUM_TIMEOUT'),
service_args=crawler.settings.get('PHANTOMJS_SERVICE_ARGS'))

首先我们在 init() 里对一些对象进行初始化,包括 PhantomJS、WebDriverWait 等对象,同时设置页面大小和页面加载超时时间。在 process_request() 方法中,我们通过 Request 的 meta 属性获取当前需要爬取的页码,调用 PhantomJS 对象的 get() 方法访问 Request 的对应的 URL。这就相当于从 Request 对象里获取请求链接,然后再用 PhantomJS 加载,而不再使用 Scrapy 里的 Downloader。 随后的处理等待和翻页的方法在此不再赘述,和前文的原理完全相同。最后,页面加载完成之后,我们调用 PhantomJS 的 page_source 属性即可获取当前页面的源代码,然后用它来直接构造并返回一个 HtmlResponse 对象。构造这个对象的时候需要传入多个参数,如 url、body 等,这些参数实际上就是它的基础属性。可以在官方文档查看 HtmlResponse 对象的结构:https://doc.scrapy.org/en/latest/topics/request-response.html,这样我们就成功利用 PhantomJS 来代替 Scrapy 完成了页面的加载,最后将 Response 返回即可。 有人可能会纳闷:为什么实现这么一个 Downloader Middleware 就可以了?之前的 Request 对象怎么办?Scrapy 不再处理了吗?Response 返回后又传递给了谁? 是的,Request 对象到这里就不会再处理了,也不会再像以前一样交给 Downloader 下载。Response 会直接传给 Spider 进行解析。 我们需要回顾一下 Downloader Middleware 的 process_request() 方法的处理逻辑,内容如下所示: 当 process_request() 方法返回 Response 对象的时候,更低优先级的 Downloader Middleware 的 process_request() 和 process_exception() 方法就不会被继续调用了,转而开始执行每个 Downloader Middleware 的 process_response() 方法,调用完毕之后直接将 Response 对象发送给 Spider 来处理。 这里直接返回了一个 HtmlResponse 对象,它是 Response 的子类,返回之后便顺次调用每个 Downloader Middleware 的 process_response() 方法。而在 process_response() 中我们没有对其做特殊处理,它会被发送给 Spider,传给 Request 的回调函数进行解析。 到现在,我们应该能了解 Downloader Middleware 实现 Selenium 对接的原理了。 在 settings.py 里,我们设置调用刚才定义的 SeleniumMiddleware、设置等待超时变量 SELENIUM_TIMEOUT、设置 PhantomJS 配置参数 PHANTOMJS_SERVICE_ARGS,如下所示:

1
DOWNLOADER_MIDDLEWARES = {'scrapyseleniumtest.middlewares.SeleniumMiddleware': 543,}

6. 解析页面

Response 对象就会回传给 Spider 内的回调函数进行解析。所以下一步我们就实现其回调函数,对网页来进行解析,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
def parse(self, response):
products = response.xpath('//div[@id="mainsrp-itemlist"]//div[@class="items"][1]//div[contains(@class, "item")]')
for product in products:
item = ProductItem()
item['price'] = ''.join(product.xpath('.//div[contains(@class, "price")]//text()').extract()).strip()
item['title'] = ''.join(product.xpath('.//div[contains(@class, "title")]//text()').extract()).strip()
item['shop'] = ''.join(product.xpath('.//div[contains(@class, "shop")]//text()').extract()).strip()
item['image'] = ''.join(product.xpath('.//div[@class="pic"]//img[contains(@class, "img")]/@data-src').extract()).strip()
item['deal'] = product.xpath('.//div[contains(@class, "deal-cnt")]//text()').extract_first()
item['location'] = product.xpath('.//div[contains(@class, "location")]//text()').extract_first()
yield item

在这里我们使用 XPath 进行解析,调用 response 变量的 xpath() 方法即可。首先我们传递选取所有商品对应的 XPath,可以匹配所有商品,随后对结果进行遍历,依次选取每个商品的名称、价格、图片等内容,构造并返回一个 ProductItem 对象。

7. 存储结果

最后我们实现一个 Item Pipeline,将结果保存到 MongoDB,如下所示:

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

class MongoPipeline(object):
def __init__(self, mongo_uri, mongo_db):
self.mongo_uri = mongo_uri
self.mongo_db = mongo_db

@classmethod
def from_crawler(cls, crawler):
return cls(mongo_uri=crawler.settings.get('MONGO_URI'), mongo_db=crawler.settings.get('MONGO_DB'))

def open_spider(self, spider):
self.client = pymongo.MongoClient(self.mongo_uri)
self.db = self.client[self.mongo_db]

def process_item(self, item, spider):
self.db[item.collection].insert(dict(item))
return item

def close_spider(self, spider):
self.client.close()

此实现和前文中存储到 MongoDB 的方法完全一致,原理不再赘述。记得在 settings.py 中开启它的调用,如下所示:

1
ITEM_PIPELINES = {'scrapyseleniumtest.pipelines.MongoPipeline': 300,}

其中,MONGO_URI 和 MONGO_DB 的定义如下所示:

1
2
MONGO_URI = 'localhost'
MONGO_DB = 'taobao'

8. 运行

整个项目就完成了,执行如下命令启动抓取即可:

1
scrapy crawl taobao

运行结果如图 13-13 所示: 图 13-13 运行结果 再查看一下 MongoDB,结果如图 13-14 所示: 图 13-14 MongoDB 结果 这样我们便成功在 Scrapy 中对接 Selenium 并实现了淘宝商品的抓取。

9. 本节代码

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

10. 结语

我们通过改写 Downloader Middleware 的方式实现了 Selenium 的对接。但这种方法其实是阻塞式的,也就是说这样就破坏了 Scrapy 异步处理的逻辑,速度会受到影响。为了不破坏其异步加载逻辑,我们可以使用 Splash 实现。下一节我们再来看看 Scrapy 对接 Splash 的方式。

Python

13.7 Item Pipeline 的用法

Item Pipeline 是项目管道。在前面我们已经了解了 Item Pipeline 的基本用法,本节我们再作详细了解它的用法。 首先我们看看 Item Pipeline 在 Scrapy 中的架构,如图 13-1 所示。 图中的最左侧即为 Item Pipeline,它的调用发生在 Spider 产生 Item 之后。当 Spider 解析完 Response 之后,Item 就会传递到 Item Pipeline,被定义的 Item Pipeline 组件会顺次调用,完成一连串的处理过程,比如数据清洗、存储等。 它的主要功能有:

  • 清洗 HTML 数据
  • 验证爬取数据,检查爬取字段
  • 查重并丢弃重复内容
  • 将爬取结果储存到数据库

1. 核心方法

我们可以自定义 Item Pipeline,只需要实现指定的方法就好,其中必须要实现的一个方法是:

  • process_item(item, spider)

另外还有几个比较实用的方法,它们分别是:

  • open_spider(spider)
  • close_spider(spider)
  • from_crawler(cls, crawler)

下面我们对这几个方法的用法作下详细的介绍:

process_item(item, spider)

process_item() 是必须要实现的方法,被定义的 Item Pipeline 会默认调用这个方法对 Item 进行处理。比如,我们可以进行数据处理或者将数据写入到数据库等操作。它必须返回 Item 类型的值或者抛出一个 DropItem 异常。 process_item() 方法的参数有如下两个。

  • item,是 Item 对象,即被处理的 Item
  • spider,是 Spider 对象,即生成该 Item 的 Spider

下面对该方法的返回类型归纳如下:

  • 如果返回的是 Item 对象,那么此 Item 会接着被低优先级的 Item Pipeline 的 process_item() 方法进行处理,直到所有的方法被调用完毕。
  • 如果抛出的是 DropItem 异常,那么此 Item 就会被丢弃,不再进行处理。

open_spider(self, spider)

open_spider() 方法是在 Spider 开启的时候被自动调用的,在这里我们可以做一些初始化操作,如开启数据库连接等。其中参数 spider 就是被开启的 Spider 对象。

close_spider(spider)

close_spider() 方法是在 Spider 关闭的时候自动调用的,在这里我们可以做一些收尾工作,如关闭数据库连接等,其中参数 spider 就是被关闭的 Spider 对象。

from_crawler(cls, crawler)

from_crawler() 方法是一个类方法,用 @classmethod 标识,是一种依赖注入的方式。它的参数是 crawler,通过 crawler 对象,我们可以拿到 Scrapy 的所有核心组件,如全局配置的每个信息,然后创建一个 Pipeline 实例。参数 cls 就是 Class,最后返回一个 Class 实例。 下面我们用一个实例来加深对 Item Pipeline 用法的理解。

2. 本节目标

我们以爬取 360 摄影美图为例,来分别实现 MongoDB 存储、MySQL 存储、Image 图片存储的三个 Pipeline。

3. 准备工作

请确保已经安装好 MongoDB 和 MySQL 数据库,安装好 Python 的 PyMongo、PyMySQL、Scrapy 框架,另外需要安装 pillow 图像处理库,如没有安装可以参考第 1 章的安装说明。

4. 抓取分析

我们这次爬取的目标网站为:https://image.so.com。打开此页面,切换到摄影页面,网页中呈现了许许多多的摄影美图。我们打开浏览器开发者工具,过滤器切换到 XHR 选项,然后下拉页面,可以看到下面就会呈现许多 Ajax 请求,如图 13-6 所示。 图 13-6 请求列表 我们查看一个请求的详情,观察返回的数据结构,如图 13-7 所示。 图 13-7 返回结果 返回格式是 JSON。其中 list 字段就是一张张图片的详情信息,包含了 30 张图片的 ID、名称、链接、缩略图等信息。另外观察 Ajax 请求的参数信息,有一个参数 sn 一直在变化,这个参数很明显就是偏移量。当 sn 为 30 时,返回的是前 30 张图片,sn 为 60 时,返回的就是第 31~60 张图片。另外,ch 参数是摄影类别,listtype 是排序方式,temp 参数可以忽略。 所以我们抓取时只需要改变 sn 的数值就好了。 下面我们用 Scrapy 来实现图片的抓取,将图片的信息保存到 MongoDB、MySQL,同时将图片存储到本地。

5. 新建项目

首先新建一个项目,命令如下:

1
scrapy startproject images360

接下来新建一个 Spider,命令如下:

1
scrapy genspider images images.so.com

这样我们就成功创建了一个 Spider。

6. 构造请求

接下来定义爬取的页数。比如爬取 50 页、每页 30 张,也就是 1500 张图片,我们可以先在 settings.py 里面定义一个变量 MAX_PAGE,添加如下定义:

1
MAX_PAGE = 50

定义 start_requests() 方法,用来生成 50 次请求,如下所示:

1
2
3
4
5
6
7
8
def start_requests(self):
data = {'ch': 'photography', 'listtype': 'new'}
base_url = 'https://image.so.com/zj?'
for page in range(1, self.settings.get('MAX_PAGE') + 1):
data['sn'] = page * 30
params = urlencode(data)
url = base_url + params
yield Request(url, self.parse)

在这里我们首先定义了初始的两个参数,sn 参数是遍历循环生成的。然后利用 urlencode() 方法将字典转化为 URL 的 GET 参数,构造出完整的 URL,构造并生成 Request。 还需要引入 scrapy.Request 和 urllib.parse 模块,如下所示:

1
2
from scrapy import Spider, Request
from urllib.parse import urlencode

再修改 settings.py 中的 ROBOTSTXT_OBEY 变量,将其设置为 False,否则无法抓取,如下所示:

1
ROBOTSTXT_OBEY = False

运行爬虫,即可以看到链接都请求成功,执行命令如下所示:

1
scrapy crawl images

运行示例结果如图 13-8 所示。 图 13-8 运行结果 所有请求的状态码都是 200,这就证明图片信息爬取成功了。

7. 提取信息

首先定义一个 Item,叫作 ImageItem,如下所示:

1
2
3
4
5
6
7
from scrapy import Item, Field
class ImageItem(Item):
collection = table = 'images'
id = Field()
url = Field()
title = Field()
thumb = Field()

在这里我们定义了 4 个字段,包括图片的 ID、链接、标题、缩略图。另外还有两个属性 collection 和 table,都定义为 images 字符串,分别代表 MongoDB 存储的 Collection 名称和 MySQL 存储的表名称。 接下来我们提取 Spider 里有关信息,将 parse() 方法改写为如下所示:

1
2
3
4
5
6
7
8
9
def parse(self, response):
result = json.loads(response.text)
for image in result.get('list'):
item = ImageItem()
item['id'] = image.get('imageid')
item['url'] = image.get('qhimg_url')
item['title'] = image.get('group_title')
item['thumb'] = image.get('qhimg_thumb_url')
yield item

首先解析 JSON,遍历其 list 字段,取出一个个图片信息,然后再对 ImageItem 赋值,生成 Item 对象。 这样我们就完成了信息的提取。

8. 存储信息

接下来我们需要将图片的信息保存到 MongoDB、MySQL,同时将图片保存到本地。

MongoDB

首先确保 MongoDB 已经正常安装并且正常运行。 我们用一个 MongoPipeline 将信息保存到 MongoDB,在 pipelines.py 里添加如下类的实现:

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

class MongoPipeline(object):
def __init__(self, mongo_uri, mongo_db):
self.mongo_uri = mongo_uri
self.mongo_db = mongo_db

@classmethod
def from_crawler(cls, crawler):
return cls(mongo_uri=crawler.settings.get('MONGO_URI'),
mongo_db=crawler.settings.get('MONGO_DB')
)

def open_spider(self, spider):
self.client = pymongo.MongoClient(self.mongo_uri)
self.db = self.client[self.mongo_db]

def process_item(self, item, spider):
self.db[item.collection].insert(dict(item))
return item

def close_spider(self, spider):
self.client.close()

这里需要用到两个变量,MONGO_URI 和 MONGO_DB,即存储到 MongoDB 的链接地址和数据库名称。我们在 settings.py 里添加这两个变量,如下所示:

1
2
MONGO_URI = 'localhost'
MONGO_DB = 'images360'

这样一个保存到 MongoDB 的 Pipeline 的就创建好了。这里最主要的方法是 process_item() 方法,直接调用 Collection 对象的 insert() 方法即可完成数据的插入,最后返回 Item 对象。

MySQL

首先确保 MySQL 已经正确安装并且正常运行。 新建一个数据库,名字还是 images360,SQL 语句如下所示:

1
CREATE DATABASE images360 DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci

新建一个数据表,包含 id、url、title、thumb 四个字段,SQL 语句如下所示:

1
CREATE TABLE images (id VARCHAR(255) NULL PRIMARY KEY, url VARCHAR(255) NULL , title VARCHAR(255) NULL , thumb VARCHAR(255) NULL)

执行完 SQL 语句之后,我们就成功创建好了数据表。接下来就可以往表里存储数据了。 接下来我们实现一个 MySQLPipeline,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import pymysql

class MysqlPipeline():
def __init__(self, host, database, user, password, port):
self.host = host
self.database = database
self.user = user
self.password = password
self.port = port

@classmethod
def from_crawler(cls, crawler):
return cls(host=crawler.settings.get('MYSQL_HOST'),
database=crawler.settings.get('MYSQL_DATABASE'),
user=crawler.settings.get('MYSQL_USER'),
password=crawler.settings.get('MYSQL_PASSWORD'),
port=crawler.settings.get('MYSQL_PORT'),
)

def open_spider(self, spider):
self.db = pymysql.connect(self.host, self.user, self.password, self.database, charset='utf8', port=self.port)
self.cursor = self.db.cursor()

def close_spider(self, spider):
self.db.close()

def process_item(self, item, spider):
data = dict(item)
keys = ', '.join(data.keys())
values = ', '.join(['% s'] * len(data))
sql = 'insert into % s (% s) values (% s)' % (item.table, keys, values)
self.cursor.execute(sql, tuple(data.values()))
self.db.commit()
return item

如前所述,这里用到的数据插入方法是一个动态构造 SQL 语句的方法。 这里又需要几个 MySQL 的配置,我们在 settings.py 里添加几个变量,如下所示:

1
2
3
4
5
MYSQL_HOST = 'localhost'
MYSQL_DATABASE = 'images360'
MYSQL_PORT = 3306
MYSQL_USER = 'root'
MYSQL_PASSWORD = '123456'

这里分别定义了 MySQL 的地址、数据库名称、端口、用户名、密码。 这样,MySQL Pipeline 就完成了。

Image Pipeline

Scrapy 提供了专门处理下载的 Pipeline,包括文件下载和图片下载。下载文件和图片的原理与抓取页面的原理一样,因此下载过程支持异步和多线程,下载十分高效。下面我们来看看具体的实现过程。 官方文档地址为:https://doc.scrapy.org/en/latest/topics/media-pipeline.html。 首先定义存储文件的路径,需要定义一个 IMAGES_STORE 变量,在 settings.py 中添加如下代码:

1
IMAGES_STORE = './images'

在这里我们将路径定义为当前路径下的 images 子文件夹,即下载的图片都会保存到本项目的 images 文件夹中。 内置的 ImagesPipeline 会默认读取 Item 的 image_urls 字段,并认为该字段是一个列表形式,它会遍历 Item 的 image_urls 字段,然后取出每个 URL 进行图片下载。 但是现在生成的 Item 的图片链接字段并不是 image_urls 字段表示的,也不是列表形式,而是单个的 URL。所以为了实现下载,我们需要重新定义下载的部分逻辑,即要自定义 ImagePipeline,继承内置的 ImagesPipeline,重写几个方法。 我们定义 ImagePipeline,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from scrapy import Request
from scrapy.exceptions import DropItem
from scrapy.pipelines.images import ImagesPipeline

class ImagePipeline(ImagesPipeline):
def file_path(self, request, response=None, info=None):
url = request.url
file_name = url.split('/')[-1]
return file_name

def item_completed(self, results, item, info):
image_paths = [x['path'] for ok, x in results if ok]
if not image_paths:
raise DropItem('Image Downloaded Failed')
return item

def get_media_requests(self, item, info):
yield Request(item['url'])

在这里我们实现了 ImagePipeline,继承 Scrapy 内置的 ImagesPipeline,重写下面几个方法。

  • get_media_requests()。它的第一个参数 item 是爬取生成的 Item 对象。我们将它的 url 字段取出来,然后直接生成 Request 对象。此 Request 加入到调度队列,等待被调度,执行下载。
  • file_path()。它的第一个参数 request 就是当前下载对应的 Request 对象。这个方法用来返回保存的文件名,直接将图片链接的最后一部分当作文件名即可。它利用 split() 函数分割链接并提取最后一部分,返回结果。这样此图片下载之后保存的名称就是该函数返回的文件名。
  • item_completed(),它是当单个 Item 完成下载时的处理方法。因为并不是每张图片都会下载成功,所以我们需要分析下载结果并剔除下载失败的图片。如果某张图片下载失败,那么我们就不需保存此 Item 到数据库。该方法的第一个参数 results 就是该 Item 对应的下载结果,它是一个列表形式,列表每一个元素是一个元组,其中包含了下载成功或失败的信息。这里我们遍历下载结果找出所有成功的下载列表。如果列表为空,那么该 Item 对应的图片下载失败,随即抛出异常 DropItem,该 Item 忽略。否则返回该 Item,说明此 Item 有效。

现在为止,三个 Item Pipeline 的定义就完成了。最后只需要启用就可以了,修改 settings.py,设置 ITEM_PIPELINES,如下所示:

1
2
3
4
5
ITEM_PIPELINES = {
'images360.pipelines.ImagePipeline': 300,
'images360.pipelines.MongoPipeline': 301,
'images360.pipelines.MysqlPipeline': 302,
}

这里注意调用的顺序。我们需要优先调用 ImagePipeline 对 Item 做下载后的筛选,下载失败的 Item 就直接忽略,它们就不会保存到 MongoDB 和 MySQL 里。随后再调用其他两个存储的 Pipeline,这样就能确保存入数据库的图片都是下载成功的。 接下来运行程序,执行爬取,如下所示:

1
scrapy crawl images

爬虫一边爬取一边下载,下载速度非常快,对应的输出日志如图 13-9 所示。 图 13-9 输出日志 查看本地 images 文件夹,发现图片都已经成功下载,如图 13-10 所示。 图 13-10 下载结果 查看 MySQL,下载成功的图片信息也已成功保存,如图 13-11 所示。 图 13-11 MySQL 结果 查看 MongoDB,下载成功的图片信息同样已成功保存,如图 13-12 所示。 图 13-12 MongoDB 结果 这样我们就可以成功实现图片的下载并把图片的信息存入数据库了。

9. 本节代码

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

10. 结语

Item Pipeline 是 Scrapy 非常重要的组件,数据存储几乎都是通过此组件实现的。请读者认真掌握此内容。

Python

13.6 Spider Middleware 的用法

Spider Middleware 是介入到 Scrapy 的 Spider 处理机制的钩子框架。我们首先来看看它的架构,如图 13-1 所示。 当 Downloader 生成 Response 之后,Response 会被发送给 Spider,在发送给 Spider 之前,Response 会首先经过 Spider Middleware 处理,当 Spider 处理生成 Item 和 Request 之后,Item 和 Request 还会经过 Spider Middleware 的处理。 Spider Middleware 有如下三个作用。

  • 我们可以在 Downloader 生成的 Response 发送给 Spider 之前,也就是在 Response 发送给 Spider 之前对 Response 进行处理。
  • 我们可以在 Spider 生成的 Request 发送给 Scheduler 之前,也就是在 Request 发送给 Scheduler 之前对 Request 进行处理。
  • 我们可以在 Spider 生成的 Item 发送给 Item Pipeline 之前,也就是在 Item 发送给 Item Pipeline 之前对 Item 进行处理。

1. 使用说明

需要说明的是,Scrapy 其实已经提供了许多 Spider Middleware,它们被 SPIDER_MIDDLEWARES_BASE 这个变量所定义。 SPIDER_MIDDLEWARES_BASE 变量的内容如下:

1
2
3
4
5
6
7
{
'scrapy.spidermiddlewares.httperror.HttpErrorMiddleware': 50,
'scrapy.spidermiddlewares.offsite.OffsiteMiddleware': 500,
'scrapy.spidermiddlewares.referer.RefererMiddleware': 700,
'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware': 800,
'scrapy.spidermiddlewares.depth.DepthMiddleware': 900,
}

和 Downloader Middleware 一样,Spider Middleware 首先加入到 SPIDER_MIDDLEWARES 设置中,该设置会和 Scrapy 中 SPIDER_MIDDLEWARES_BASE 定义的 Spider Middleware 合并。然后根据键值的数字优先级排序,得到一个有序列表。第一个 Middleware 是最靠近引擎的,最后一个 Middleware 是最靠近 Spider 的。

2. 核心方法

Scrapy 内置的 Spider Middleware 为 Scrapy 提供了基础的功能。如果我们想要扩展其功能,只需要实现某几个方法即可。 每个 Spider Middleware 都定义了以下一个或多个方法的类,核心方法有如下 4 个。

  • process_spider_input(response, spider)
  • process_spider_output(response, result, spider)
  • process_spider_exception(response, exception, spider)
  • process_start_requests(start_requests, spider)

只需要实现其中一个方法就可以定义一个 Spider Middleware。下面我们来看看这 4 个方法的详细用法。

process_spider_input(response, spider)

当 Response 通过 Spider Middleware 时,该方法被调用,处理该 Response。 方法的参数有两个:

  • response,即 Response 对象,即被处理的 Response
  • spider,即 Spider 对象,即该 response 对应的 Spider

process_spider_input() 应该返回 None 或者抛出一个异常。

  • 如果其返回 None ,Scrapy 将会继续处理该 Response,调用所有其他的 Spider Middleware 直到 Spider 处理该 Response。
  • 如果其抛出一个异常,Scrapy 将不会调用任何其他 Spider Middlewar e 的 process_spider_input() 方法,并调用 Request 的 errback() 方法。 errback 的输出将会以另一个方向被重新输入到中间件中,使用 process_spider_output() 方法来处理,当其抛出异常时则调用 process_spider_exception() 来处理。

process_spider_output(response, result, spider)

当 Spider 处理 Response 返回结果时,该方法被调用。 方法的参数有三个:

  • response,即 Response 对象,即生成该输出的 Response
  • result,包含 Request 或 Item 对象的可迭代对象,即 Spider 返回的结果
  • spider,即 Spider 对象,即其结果对应的 Spider

process_spider_output() 必须返回包含 Request 或 Item 对象的可迭代对象。

process_spider_exception(response, exception, spider)

当 Spider 或 Spider Middleware 的 process_spider_input() 方法抛出异常时, 该方法被调用。 方法的参数有三个:

  • response,即 Response 对象,即异常被抛出时被处理的 Response
  • exception,即 Exception 对象,被抛出的异常
  • spider,即 Spider 对象,即抛出该异常的 Spider

process_spider_exception() 必须要么返回 None , 要么返回一个包含 Response 或 Item 对象的可迭代对象。

  • 如果其返回 None ,Scrapy 将继续处理该异常,调用其他 Spider Middleware 中的 process_spider_exception() 方法,直到所有 Spider Middleware 都被调用。
  • 如果其返回一个可迭代对象,则其他 Spider Middleware 的 process_spider_output() 方法被调用, 其他的 process_spider_exception() 将不会被调用。

process_start_requests(start_requests, spider)

该方法以 Spider 启动的 Request 为参数被调用,执行的过程类似于 process_spider_output() ,只不过其没有相关联的 Response 并且必须返回 Request。 方法的参数有两个:

  • start_requests,即包含 Request 的可迭代对象,即 Start Requests
  • spider,即 Spider 对象,即 Start Requests 所属的 Spider

其必须返回另一个包含 Request 对象的可迭代对象。

3. 结语

本节介绍了 Spider Middleware 的基本原理和自定义 Spider Middleware 的方法。Spider Middleware 使用的频率不如 Downloader Middleware 的高,在必要的情况下它可以用来方便数据的处理。

Python

13.5 Downloader Middleware 的用法

Downloader Middleware 即下载中间件,它是处于 Scrapy 的 Request 和 Response 之间的处理模块。我们首先来看看它的架构,如图 13-1 所示。 Scheduler 从队列中拿出一个 Request 发送给 Downloader 执行下载,这个过程会经过 Downloader Middleware 的处理。另外,当 Downloader 将 Request 下载完成得到 Response 返回给 Spider 时会再次经过 Downloader Middleware 处理。 也就是说,Downloader Middleware 在整个架构中起作用的位置是以下两个。

  • 在 Scheduler 调度出队列的 Request 发送给 Downloader 下载之前,也就是我们可以在 Request 执行下载之前对其进行修改。
  • 在下载后生成的 Response 发送给 Spider 之前,也就是我们可以在生成 Resposne 被 Spider 解析之前对其进行修改。

Downloader Middleware 的功能十分强大,修改 User-Agent、处理重定向、设置代理、失败重试、设置 Cookies 等功能都需要借助它来实现。下面我们来了解一下 Downloader Middleware 的详细用法。

1. 使用说明

需要说明的是,Scrapy 其实已经提供了许多 Downloader Middleware,比如负责失败重试、自动重定向等功能的 Middleware,它们被 DOWNLOADER_MIDDLEWARES_BASE 变量所定义。 DOWNLOADER_MIDDLEWARES_BASE 变量的内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware': 100,
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware': 300,
'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware': 350,
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware': 400,
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': 500,
'scrapy.downloadermiddlewares.retry.RetryMiddleware': 550,
'scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware': 560,
'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware': 580,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 590,
'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': 600,
'scrapy.downloadermiddlewares.cookies.CookiesMiddleware': 700,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 750,
'scrapy.downloadermiddlewares.stats.DownloaderStats': 850,
'scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware': 900,
}

这是一个字典格式,字典的键名是 Scrapy 内置的 Downloader Middleware 的名称,键值代表了调用的优先级,优先级是一个数字,数字越小代表越靠近 Scrapy 引擎,数字越大代表越靠近 Downloader。每个 Downloader Middleware 都可以定义 process_request() 和 request_response() 方法来分别处理请求和响应,对于 process_request() 方法来说,优先级数字越小越先被调用,对于 process_response() 方法来说,优先级数字越大越先被调用。。 如果自己定义的 Downloader Middleware 要添加到项目里,DOWNLOADER_MIDDLEWARES_BASE 变量不能直接修改。Scrapy 提供了另外一个设置变量 DOWNLOADER_MIDDLEWARES,我们直接修改这个变量就可以添加自己定义的 Downloader Middleware,以及禁用 DOWNLOADER_MIDDLEWARES_BASE 里面定义的 Downloader Middleware。下面我们具体来看看 Downloader Middleware 的使用方法。

2. 核心方法

Scrapy 内置的 Downloader Middleware 为 Scrapy 提供了基础的功能,但在项目实战中我们往往需要单独定义 Downloader Middleware。不用担心,这个过程非常简单,我们只需要实现某几个方法即可。 每个 Downloader Middleware 都定义了一个或多个方法的类,核心的方法有如下三个。

  • process_request(request, spider)
  • process_response(request, response, spider)
  • process_exception(request, exception, spider)

我们只需要实现至少一个方法,就可以定义一个 Downloader Middleware。下面我们来看看这三个方法的详细用法。

process_request(request, spider)

Request 被 Scrapy 引擎调度给 Downloader 之前,process_request() 方法就会被调用,也就是在 Request 从队列里调度出来到 Downloader 下载执行之前,我们都可以用 process_request() 方法对 Request 进行处理。方法的返回值必须为 None、Response 对象、Request 对象之一,或者抛出 IgnoreRequest 异常。 process_request() 方法的参数有如下两个。

  • request,即 Request 对象,即被处理的 Request
  • spider,即 Spdier 对象,即此 Request 对应的 Spider

返回类型不同,产生的效果也不同。下面归纳一下不同的返回情况。

  • 当返回是 None 时,Scrapy 将继续处理该 Request,接着执行其他 Downloader Middleware 的 process_request() 方法,一直到 Downloader 把 Request 执行后得到 Response 才结束。这个过程其实就是修改 Request 的过程,不同的 Downloader Middleware 按照设置的优先级顺序依次对 Request 进行修改,最后送至 Downloader 执行。
  • 当返回为 Response 对象时,更低优先级的 Downloader Middleware 的 process_request() 和 process_exception() 方法就不会被继续调用,每个 Downloader Middleware 的 process_response() 方法转而被依次调用。调用完毕之后,直接将 Response 对象发送给 Spider 来处理。
  • 当返回为 Request 对象时,更低优先级的 Downloader Middleware 的 process_request() 方法会停止执行。这个 Request 会重新放到调度队列里,其实它就是一个全新的 Request,等待被调度。如果被 Scheduler 调度了,那么所有的 Downloader Middleware 的 process_request() 方法会被重新按照顺序执行。
  • 如果 IgnoreRequest 异常抛出,则所有的 Downloader Middleware 的 process_exception() 方法会依次执行。如果没有一个方法处理这个异常,那么 Request 的 errorback() 方法就会回调。如果该异常还没有被处理,那么它便会被忽略。

process_response(request, response, spider)

Downloader 执行 Request 下载之后,会得到对应的 Response。Scrapy 引擎便会将 Response 发送给 Spider 进行解析。在发送之前,我们都可以用 process_response() 方法来对 Response 进行处理。方法的返回值必须为 Request 对象、Response 对象之一,或者抛出 IgnoreRequest 异常。 process_response() 方法的参数有如下三个。

  • request,是 Request 对象,即此 Response 对应的 Request。
  • response,是 Response 对象,即此被处理的 Response。
  • spider,是 Spider 对象,即此 Response 对应的 Spider。

下面对不同的返回情况做一下归纳:

  • 当返回为 Request 对象时,更低优先级的 Downloader Middleware 的 process_response() 方法不会继续调用。该 Request 对象会重新放到调度队列里等待被调度,它相当于一个全新的 Request。然后,该 Request 会被 process_request() 方法顺次处理。
  • 当返回为 Response 对象时,更低优先级的 Downloader Middleware 的 process_response() 方法会继续调用,继续对该 Response 对象进行处理。
  • 如果 IgnoreRequest 异常抛出,则 Request 的 errorback() 方法会回调。如果该异常还没有被处理,那么它便会被忽略。

process_exception(request, exception, spider)

当 Downloader 或 process_request() 方法抛出异常时,例如抛出 IgnoreRequest 异常,process_exception() 方法就会被调用。方法的返回值必须为 None、Response 对象、Request 对象之一。 process_exception() 方法的参数有如下三个。

  • request,即 Request 对象,即产生异常的 Request
  • exception,即 Exception 对象,即抛出的异常
  • spdier,即 Spider 对象,即 Request 对应的 Spider

下面归纳一下不同的返回值。

  • 当返回为 None 时,更低优先级的 Downloader Middleware 的 process_exception() 会被继续顺次调用,直到所有的方法都被调度完毕。
  • 当返回为 Response 对象时,更低优先级的 Downloader Middleware 的 process_exception() 方法不再被继续调用,每个 Downloader Middleware 的 process_response() 方法转而被依次调用。
  • 当返回为 Request 对象时,更低优先级的 Downloader Middleware 的 process_exception() 也不再被继续调用,该 Request 对象会重新放到调度队列里面等待被调度,它相当于一个全新的 Request。然后,该 Request 又会被 process_request() 方法顺次处理。

以上内容便是这三个方法的详细使用逻辑。在使用它们之前,请先对这三个方法的返回值的处理情况有一个清晰的认识。在自定义 Downloader Middleware 的时候,也一定要注意每个方法的返回类型。 下面我们用一个案例实战来加深一下对 Downloader Middleware 用法的理解。

3. 项目实战

新建一个项目,命令如下所示:

1
scrapy startproject scrapydownloadertest

新建了一个 Scrapy 项目,名为 scrapydownloadertest。进入项目,新建一个 Spider,命令如下所示:

1
scrapy genspider httpbin httpbin.org

新建了一个 Spider,名为 httpbin,源代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import scrapy
class HttpbinSpider(scrapy.Spider):
name = 'httpbin'
allowed_domains = ['httpbin.org']
start_urls = ['http://httpbin.org/']

def parse(self, response):
pass
```接下来我们修改 start_urls 为:`['http://httpbin.org/']`。随后将 parse() 方法添加一行日志输出,将 response 变量的 text 属性输出出来,这样我们便可以看到 Scrapy 发送的 Request 信息了。

修改 Spider 内容如下所示:

```python
import scrapy

class HttpbinSpider(scrapy.Spider):
name = 'httpbin'
allowed_domains = ['httpbin.org']
start_urls = ['http://httpbin.org/get']

def parse(self, response):
self.logger.debug(response.text)

接下来运行此 Spider,执行如下命令:

1
scrapy crawl httpbin

Scrapy 运行结果包含 Scrapy 发送的 Request 信息,内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
{"args": {}, 
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip,deflate,br",
"Accept-Language": "en",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "Scrapy/1.4.0 (+http://scrapy.org)"
},
"origin": "60.207.237.85",
"url": "http://httpbin.org/get"
}

我们观察一下 Headers,Scrapy 发送的 Request 使用的 User-Agent 是 Scrapy/1.4.0(+http://scrapy.org),这其实是由,这其实是由) Scrapy 内置的 UserAgentMiddleware 设置的,UserAgentMiddleware 的源码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from scrapy import signals

class UserAgentMiddleware(object):
def __init__(self, user_agent='Scrapy'):
self.user_agent = user_agent

@classmethod
def from_crawler(cls, crawler):
o = cls(crawler.settings['USER_AGENT'])
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
return o

def spider_opened(self, spider):
self.user_agent = getattr(spider, 'user_agent', self.user_agent)

def process_request(self, request, spider):
if self.user_agent:
request.headers.setdefault(b'User-Agent', self.user_agent)

在 from_crawler() 方法中,首先尝试获取 settings 里面 USER_AGENT,然后把 USER_AGENT 传递给init() 方法进行初始化,其参数就是 user_agent。如果没有传递 USER_AGENT 参数就默认设置为 Scrapy 字符串。我们新建的项目没有设置 USER_AGENT,所以这里的 user_agent 变量就是 Scrapy。接下来,在 process_request() 方法中,将 user-agent 变量设置为 headers 变量的一个属性,这样就成功设置了 User-Agent。因此,User-Agent 就是通过此 Downloader Middleware 的 process_request() 方法设置的。 修改请求时的 User-Agent 可以有两种方式:一是修改 settings 里面的 USER_AGENT 变量;二是通过 Downloader Middleware 的 process_request() 方法来修改。 第一种方法非常简单,我们只需要在 setting.py 里面加一行 USER_AGENT 的定义即可:

1
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36'

一般推荐使用此方法来设置。但是如果想设置得更灵活,比如设置随机的 User-Agent,那就需要借助 Downloader Middleware 了。所以接下来我们用 Downloader Middleware 实现一个随机 User-Agent 的设置。 在 middlewares.py 里面添加一个 RandomUserAgentMiddleware 的类,如下所示:

1
2
3
4
5
6
7
8
9
10
11
import random

class RandomUserAgentMiddleware():
def __init__(self):
self.user_agents = ['Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)',
'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.2 (KHTML, like Gecko) Chrome/22.0.1216.0 Safari/537.2',
'Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:15.0) Gecko/20100101 Firefox/15.0.1'
]

def process_request(self, request, spider):
request.headers['User-Agent'] = random.choice(self.user_agents)

我们首先在类的 init() 方法中定义了三个不同的 User-Agent,并用一个列表来表示。接下来实现了 process_request() 方法,它有一个参数 request,我们直接修改 request 的属性即可。在这里我们直接设置了 request 对象的 headers 属性的 User-Agent,设置内容是随机选择的 User-Agent,这样一个 Downloader Middleware 就写好了。 不过,要使之生效我们还需要再去调用这个 Downloader Middleware。在 settings.py 中,将 DOWNLOADER_MIDDLEWARES 取消注释,并设置成如下内容:

1
DOWNLOADER_MIDDLEWARES = {'scrapydownloadertest.middlewares.RandomUserAgentMiddleware': 543,}

接下来我们重新运行 Spider,就可以看到 User-Agent 被成功修改为列表中所定义的随机的一个 User-Agent 了:

1
2
3
4
5
6
7
8
9
10
11
12
{"args": {}, 
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip,deflate,br",
"Accept-Language": "en",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)"
},
"origin": "60.207.237.85",
"url": "http://httpbin.org/get"
}

我们就通过实现 Downloader Middleware 并利用 process_request() 方法成功设置了随机的 User-Agent。 另外,Downloader Middleware 还有 process_response() 方法。Downloader 对 Request 执行下载之后会得到 Response,随后 Scrapy 引擎会将 Response 发送回 Spider 进行处理。但是在 Response 被发送给 Spider 之前,我们同样可以使用 process_response() 方法对 Response 进行处理。比如这里修改一下 Response 的状态码,在 RandomUserAgentMiddleware 添加如下代码:

1
2
3
def process_response(self, request, response, spider):
response.status = 201
return response

我们将 response 对象的 status 属性修改为 201,随后将 response 返回,这个被修改后的 Response 就会被发送到 Spider。 我们再在 Spider 里面输出修改后的状态码,在 parse() 方法中添加如下的输出语句:

1
self.logger.debug('Status Code: ' + str(response.status))

重新运行之后,控制台输出了如下内容:

1
[httpbin] DEBUG: Status Code: 201

可以发现,Response 的状态码成功修改了。 因此要想对 Response 进行后处理,就可以借助于 process_response() 方法。 另外还有一个 process_exception() 方法,它是用来处理异常的方法。如果需要异常处理的话,我们可以调用此方法。不过这个方法的使用频率相对低一些,在此不用实例演示。

4. 本节代码

本节源代码为:https://github.com/Python3WebSpider/ScrapyDownloaderTest

5. 结语

本节讲解了 Downloader Middleware 的基本用法。此组件非常重要,是做异常处理和应对反爬处理的核心。后面我们会在实战中应用此组件来处理代理、Cookies 等内容。

Python

13.1 Scrapy 框架介绍

Scrapy 是一个基于 Twisted 的异步处理框架,是纯 Python 实现的爬虫框架,其架构清晰,模块之间的耦合程度低,可扩展性极强,可以灵活完成各种需求。我们只需要定制开发几个模块就可以轻松实现一个爬虫。

1. 架构介绍

首先我们来看下 Scrapy 框架的架构,如图 13-1 所示: 图 13-1 Scrapy 架构 它可以分为如下的几个部分。

  • Engine,引擎,用来处理整个系统的数据流处理,触发事务,是整个框架的核心。
  • Item,项目,它定义了爬取结果的数据结构,爬取的数据会被赋值成该对象。
  • Scheduler, 调度器,用来接受引擎发过来的请求并加入队列中,并在引擎再次请求的时候提供给引擎。
  • Downloader,下载器,用于下载网页内容,并将网页内容返回给蜘蛛。
  • Spiders,蜘蛛,其内定义了爬取的逻辑和网页的解析规则,它主要负责解析响应并生成提取结果和新的请求。
  • Item Pipeline,项目管道,负责处理由蜘蛛从网页中抽取的项目,它的主要任务是清洗、验证和存储数据。
  • Downloader Middlewares,下载器中间件,位于引擎和下载器之间的钩子框架,主要是处理引擎与下载器之间的请求及响应。
  • Spider Middlewares, 蜘蛛中间件,位于引擎和蜘蛛之间的钩子框架,主要工作是处理蜘蛛输入的响应和输出的结果及新的请求。

2. 数据流

Scrapy 中的数据流由引擎控制,其过程如下:

  • Engine 首先打开一个网站,找到处理该网站的 Spider 并向该 Spider 请求第一个要爬取的 URL。
  • Engine 从 Spider 中获取到第一个要爬取的 URL 并通过 Scheduler 以 Request 的形式调度。
  • Engine 向 Scheduler 请求下一个要爬取的 URL。
  • Scheduler 返回下一个要爬取的 URL 给 Engine,Engine 将 URL 通过 Downloader Middlewares 转发给 Downloader 下载。
  • 一旦页面下载完毕, Downloader 生成一个该页面的 Response,并将其通过 Downloader Middlewares 发送给 Engine。
  • Engine 从下载器中接收到 Response 并通过 Spider Middlewares 发送给 Spider 处理。
  • Spider 处理 Response 并返回爬取到的 Item 及新的 Request 给 Engine。
  • Engine 将 Spider 返回的 Item 给 Item Pipeline,将新的 Request 给 Scheduler。
  • 重复第二步到最后一步,直到 Scheduler 中没有更多的 Request,Engine 关闭该网站,爬取结束。

通过多个组件的相互协作、不同组件完成工作的不同、组件对异步处理的支持,Scrapy 最大限度地利用了网络带宽,大大提高了数据爬取和处理的效率。

3. 项目结构

Scrapy 框架和 pyspider 不同,它是通过命令行来创建项目的,代码的编写还是需要 IDE。项目创建之后,项目文件结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
scrapy.cfg
project/
__init__.py
items.py
pipelines.py
settings.py
middlewares.py
spiders/
__init__.py
spider1.py
spider2.py
...

在此要将各个文件的功能描述如下:

  • scrapy.cfg:它是 Scrapy 项目的配置文件,其内定义了项目的配置文件路径、部署相关信息等内容。
  • items.py:它定义 Item 数据结构,所有的 Item 的定义都可以放这里。
  • pipelines.py:它定义 Item Pipeline 的实现,所有的 Item Pipeline 的实现都可以放这里。
  • settings.py:它定义项目的全局配置。
  • middlewares.py:它定义 Spider Middlewares 和 Downloader Middlewares 的实现。
  • spiders:其内包含一个个 Spider 的实现,每个 Spider 都有一个文件。

4. 结语

本节介绍了 Scrapy 框架的基本架构、数据流过程以及项目结构。后面我们会详细了解 Scrapy 的用法,感受它的强大。

Python

9.4 ADSL 拨号代理

我们尝试维护过一个代理池。代理池可以挑选出许多可用代理,但是常常其稳定性不高、响应速度慢,而且这些代理通常是公共代理,可能不止一人同时使用,其 IP 被封的概率很大。另外,这些代理可能有效时间比较短,虽然代理池一直在筛选,但如果没有及时更新状态,也有可能获取到不可用的代理。 如果要追求更加稳定的代理,就需要购买专有代理或者自己搭建代理服务器。但是服务器一般都是固定的 IP,我们总不能搭建 100 个代理就用 100 台服务器吧,这显然是不现实的。 所以,ADSL 动态拨号主机就派上用场了。下面我们来了解一下 ADSL 拨号代理服务器的相关设置。

1. 什么是 ADSL

ADSL(Asymmetric Digital Subscriber Line,非对称数字用户环路),它的上行和下行带宽不对称,它采用频分复用技术把普通的电话线分成了电话、上行和下行 3 个相对独立的信道,从而避免了相互之间的干扰。 ADSL 通过拨号的方式上网,需要输入 ADSL 账号和密码,每次拨号就更换一个 IP。IP 分布在多个 A 段,如果 IP 都能使用,则意味着 IP 量级可达千万。如果我们将 ADSL 主机作为代理,每隔一段时间主机拨号就换一个 IP,这样可以有效防止 IP 被封禁。另外,主机的稳定性很好,代理响应速度很快。

2. 准备工作

首先需要成功安装 Redis 数据库并启动服务,另外还需要安装 requests、redis-py、Tornado 库。如果没有安装,读者可以参考第一章的安装说明。

3. 购买主机

我们先购买一台动态拨号 VPS 主机,这样的主机服务商相当多。在这里使用了云立方,官方网站:http://www.yunlifang.cn/dynamicvps.asp。 建议选择电信线路。可以自行选择主机配置,主要考虑带宽是否满足需求。 然后进入拨号主机的后台,预装一个操作系统,如图 9-10 所示。 图 9-10 预装操作系统 推荐安装 CentOS 7 系统。 然后找到远程管理面板远程连接的用户名和密码,也就是 SSH 远程连接服务器的信息。比如我使用的 IP 和端口是 153.36.65.214:20063,用户名是 root。命令行下输入如下内容:

1
ssh root@153.36.65.214 -p 20063

输入管理密码,就可以连接上远程服务器了。 进入之后,我们发现一个可用的脚本文件 ppp.sh,这是拨号初始化的脚本。运行此脚本会提示输入拨号的用户名和密码,然后它就开始各种拨号配置。一次配置成功,后面拨号就不需要重复输入用户名和密码。 运行 ppp.sh 脚本,输入用户名、密码等待它的配置完成,如图 9-11 所示。 图 9-11 配置页面 提示成功之后就可以进行拨号了。注意,在拨号之前测试 ping 任何网站都是不通的,因为当前网络还没联通。输入如下拨号命令:

1
adsl-start

拨号命令成功运行,没有报错信息,耗时约几秒。接下来再去 ping 外网就可以通了。 如果要停止拨号,可以输入如下命令:

1
adsl-stop

之后,可以发现又连不通网络了,如图 9-12 所示。 图 9-12 拨号建立连接 断线重播的命令就是二者组合起来,先执行 adsl-stop,再执行 adsl-start。每次拨号,ifconfig 命令观察主机的 IP,发现主机的 IP 一直在变化,网卡名称叫作 ppp0,如图 9-13 所示。 图 9-13 网络设备信息 接下来,我们要做两件事:一是怎样将主机设置为代理服务器,二是怎样实时获取拨号主机的 IP。

4. 设置代理服务器

在 Linux 下搭建 HTTP 代理服务器,推荐 TinyProxy 和 Squid,配置都非常简单。在这里我们以 TinyProxy 为例来讲解一下怎样搭建代理服务器。

安装 TinyProxy

第一步就是安装 TinyProxy 软件。在这里我使用的系统是 CentOS,所以使用 yum 来安装。如果是其他系统,如 Ubuntu,可以选择 apt-get 等命令安装。 命令行执行 yum 安装指令:

1
2
3
yum install -y epel-release
yum update -y
yum install -y tinyproxy

运行完成之后就可以完成 tinyproxy 的安装了。

配置 TinyProxy

TinyProxy 安装完成之后还要配置一下才可以用作代理服务器。我们需要编辑配置文件,此文件一般的路径是 /etc/tinyproxy/tinyproxy.conf。 可以看到有一行

1
Port 8888

在这里可以设置代理的端口,默认是 8888。 继续向下找到如下代码:

1
Allow 127.0.0.1

这行代码表示被允许连接的主机 IP。如果希望连接任何主机,那就直接将这行代码注释即可。在这里我们选择直接注释,也就是任何主机都可以使用这台主机作为代理服务器。 修改为如下代码:

1
# Allow 127.0.0.1

设置完成之后重启 TinyProxy 即可:

1
2
systemctl enable tinyproxy.service
systemctl restart tinyproxy.service

防火墙开放该端口:

1
iptables -I INPUT -p tcp --dport 8888 -j ACCEPT

当然如果想直接关闭防火墙也可以:

1
systemctl stop firewalld.service

这样我们就完成了 TinyProxy 的配置了。

验证 TinyProxy

首先,用 ifconfig 查看当前主机的 IP。比如,当前我的主机拨号 IP 为 112.84.118.216,在其他的主机运行测试一下。 用 curl 命令设置代理请求 httpbin,检测代理是否生效。

1
curl -x 112.84.118.216:8888 httpbin.org/get

运行结果如图 9-14 所示: 图 9-14 运行结果 如果有正常的结果输出,并且 origin 的值为代理 IP 的地址,就证明 TinyProxy 配置成功了。

5. 动态获取 IP

现在可以执行命令让主机动态切换 IP,也在主机上搭建了代理服务器。我们只需要知道拨号后的 IP 就可以使用代理。 我们考虑到,在一台主机拨号切换 IP 的间隙代理是不可用的,在这拨号的几秒时间内如果有第二台主机顶替第一台主机,那就可以解决拨号间隙代理无法使用的问题了。所以我们要设计的架构必须要考虑支持多主机的问题。 假如有 10 台拨号主机同时需要维护,而爬虫需要使用这 10 台主机的代理,那么在爬虫端维护的开销是非常大的。如果爬虫在不同的机器上运行,那么每个爬虫必须要获得这 10 台拨号主机的配置,这显然是不理想的。 为了更加方便地使用代理,我们可以像上文的代理池一样定义一个统一的代理接口,爬虫端只需要配置代理接口即可获取可用代理。要搭建一个接口,就势必需要一台服务器,而接口的数据从哪里获得呢,当然最理想的还是选择数据库。 比如我们需要同时维护 10 台拨号主机,每台拨号主机都会定时拨号,那这样每台主机在某个时刻可用的代理只有一个,所以我们没有必要存储之前的拨号代理,因为重新拨号之后之前的代理已经不能用了,所以只需要将之前的代理更新其内容就好了。数据库要做的就是定时对每台主机的代理进行更新,而更新时又需要拨号主机的唯一标识,根据主机标识查出这条数据,然后将这条数据对应的代理更新。 所以数据库端就需要存储一个主机标识到代理的映射关系。那么很自然地我们就会想到关系型数据库,如 MySQL 或者 Redis 的 Hash 存储,只需存储一个映射关系,不需要很多字段,而且 Redis 比 MySQL 效率更高、使用更方便,所以最终选定的存储方式就是 Redis 的 Hash。

6. 存储模块

那么接下来我们要做可被远程访问的 Redis 数据库,各个拨号机器只需要将各自的主机标识和当前 IP 和端口(也就是代理)发送给数据库就好了。 先定义一个操作 Redis 数据库的类,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import redis
import random

# Redis 数据库 IP
REDIS_HOST = 'remoteaddress'
# Redis 数据库密码,如无则填 None
REDIS_PASSWORD = 'foobared'
# Redis 数据库端口
REDIS_PORT = 6379
# 代理池键名
PROXY_KEY = 'adsl'

class RedisClient(object):
def __init__(self, host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, proxy_key=PROXY_KEY):
"""
初始化 Redis 连接
:param host: Redis 地址
:param port: Redis 端口
:param password: Redis 密码
:param proxy_key: Redis 哈希表名
"""
self.db = redis.StrictRedis(host=host, port=port, password=password, decode_responses=True)
self.proxy_key = proxy_key

def set(self, name, proxy):
"""
设置代理
:param name: 主机名称
:param proxy: 代理
:return: 设置结果
"""
return self.db.hset(self.proxy_key, name, proxy)

def get(self, name):
"""
获取代理
:param name: 主机名称
:return: 代理
"""
return self.db.hget(self.proxy_key, name)

def count(self):
"""
获取代理总数
:return: 代理总数
"""
return self.db.hlen(self.proxy_key)

def remove(self, name):
"""
删除代理
:param name: 主机名称
:return: 删除结果
"""
return self.db.hdel(self.proxy_key, name)

def names(self):
"""
获取主机名称列表
:return: 获取主机名称列表
"""
return self.db.hkeys(self.proxy_key)

def proxies(self):
"""
获取代理列表
:return: 代理列表
"""
return self.db.hvals(self.proxy_key)

def random(self):
"""
随机获取代理
:return:
"""
proxies = self.proxies()
return random.choice(proxies)

def all(self):
"""
获取字典
:return:
"""return self.db.hgetall(self.proxy_key)

这里定义了一个 RedisClient 类,在init() 方法中初始化了 Redis 连接,其中 REDIS_HOST 就是远程 Redis 的地址,REDIS_PASSWORD 是密码,REDIS_PORT 是端口,PROXY_KEY 是存储代理的散列表的键名。 接下来定义了一个 set() 方法,这个方法用来向散列表添加映射关系。映射是从主机标识到代理的映射,比如一台主机的标识为 adsl1,当前的代理为 118.119.111.172:8888,那么散列表中就会存储一个 key 为 adsl1、value 为 118.119.111.172:8888 的映射,Hash 结构如图 9-15 所示。 图 9-15 Hash 结构 如果有多台主机,只需要向 Hash 中添加映射即可。 另外,get() 方法就是从散列表中取出某台主机对应的代理。remove() 方法则是从散列表中移除对应的主机的代理。还有 names()、proxies()、all() 方法则是分别获取散列表中的主机列表、代理列表及所有主机代理映射。count() 方法则是返回当前散列表的大小,也就是可用代理的数目。 最后还有一个比较重要的方法 random(),它随机从散列表中取出一个可用代理,类似前面代理池的思想,确保每个代理都能被取到。 如果要对数据库进行操作,只需要初始化 RedisClient 对象,然后调用它的 set() 或者 remove() 方法,即可对散列表进行设置和删除。

7. 拨号模块

接下来要做的就是拨号,并把新的 IP 保存到 Redis 散列表里。 首先是拨号定时,它分为定时拨号和非定时拨号两种选择。 非定时拨号:最好的方法就是向该主机发送一个信号,然后主机就启动拨号,但这样做的话,我们首先要搭建一个重新拨号的接口,如搭建一个 Web 接口,请求该接口即进行拨号,但开始拨号之后,此时主机的状态就从在线转为离线,而此时的 Web 接口也就相应失效了,拨号过程无法再连接,拨号之后接口的 IP 也变了,所以我们无法通过接口来方便地控制拨号过程和获取拨号结果,下次拨号还得改变拨号请求接口,所以非定时拨号的开销还是比较大的。 定时拨号:我们只需要在拨号主机上运行定时脚本即可,每隔一段时间拨号一次,更新 IP,然后将 IP 在 Redis 散列表中更新即可,非常简单易用,另外可以适当将拨号频率调高一点,减少短时间内 IP 被封的可能性。 在这里选择定时拨号。 接下来就是获取 IP。获取拨号后的 IP 非常简单,只需要调用 ifconfig 命令,然后解析出对应网卡的 IP 即可。 获取了 IP 之后,我们还需要进行有效性检测。拨号主机可以自己检测,比如可以利用 requests 设置自身的代理请求外网,如果成功,那么证明代理可用,然后再修改 Redis 散列表,更新代理。 需要注意,由于在拨号的间隙拨号主机是离线状态,而此时 Redis 散列表中还存留了上次的代理,一旦这个代理被取用了,该代理是无法使用的。为了避免这个情况,每台主机在拨号之前还需要将自身的代理从 Redis 散列表中移除。 这样基本的流程就理顺了,我们用如下代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import re
import time
import requests
from requests.exceptions import ConnectionError, ReadTimeout
from db import RedisClient

# 拨号网卡
ADSL_IFNAME = 'ppp0'
# 测试 URL
TEST_URL = 'http://www.baidu.com'
# 测试超时时间
TEST_TIMEOUT = 20
# 拨号间隔
ADSL_CYCLE = 100
# 拨号出错重试间隔
ADSL_ERROR_CYCLE = 5
# ADSL 命令
ADSL_BASH = 'adsl-stop;adsl-start'
# 代理运行端口
PROXY_PORT = 8888
# 客户端唯一标识
CLIENT_NAME = 'adsl1'

class Sender():
def get_ip(self, ifname=ADSL_IFNAME):
"""
获取本机 IP
:param ifname: 网卡名称
:return:
"""
(status, output) = subprocess.getstatusoutput('ifconfig')
if status == 0:
pattern = re.compile(ifname + '.*?inet.*?(d+.d+.d+.d+).*?netmask', re.S)
result = re.search(pattern, output)
if result:
ip = result.group(1)
return ip

def test_proxy(self, proxy):
"""
测试代理
:param proxy: 代理
:return: 测试结果
"""
try:
response = requests.get(TEST_URL, proxies={
'http': 'http://' + proxy,
'https': 'https://' + proxy
}, timeout=TEST_TIMEOUT)
if response.status_code == 200:
return True
except (ConnectionError, ReadTimeout):
return False

def remove_proxy(self):
"""
移除代理
:return: None
"""
self.redis = RedisClient()
self.redis.remove(CLIENT_NAME)
print('Successfully Removed Proxy')

def set_proxy(self, proxy):
"""
设置代理
:param proxy: 代理
:return: None
"""
self.redis = RedisClient()
if self.redis.set(CLIENT_NAME, proxy):
print('Successfully Set Proxy', proxy)

def adsl(self):
"""
拨号主进程
:return: None
"""
while True:
print('ADSL Start, Remove Proxy, Please wait')
self.remove_proxy()
(status, output) = subprocess.getstatusoutput(ADSL_BASH)
if status == 0:
print('ADSL Successfully')
ip = self.get_ip()
if ip:
print('Now IP', ip)
print('Testing Proxy, Please Wait')
proxy = '{ip}:{port}'.format(ip=ip, port=PROXY_PORT)
if self.test_proxy(proxy):
print('Valid Proxy')
self.set_proxy(proxy)
print('Sleeping')
time.sleep(ADSL_CYCLE)
else:
print('Invalid Proxy')
else:
print('Get IP Failed, Re Dialing')
time.sleep(ADSL_ERROR_CYCLE)
else:
print('ADSL Failed, Please Check')
time.sleep(ADSL_ERROR_CYCLE)
def run():
sender = Sender()
sender.adsl()

在这里定义了一个 Sender 类,它的主要作用是执行定时拨号,并将新的 IP 测试通过之后更新到远程 Redis 散列表里。 主方法是 adsl() 方法,它首先是一个无限循环,循环体内就是拨号的逻辑。 adsl() 方法首先调用了 remove_proxy() 方法,将远程 Redis 散列表中本机对应的代理移除,避免拨号时本主机的残留代理被取到。 接下来利用 subprocess 模块来执行拨号脚本,拨号脚本很简单,就是 stop 之后再 start,这里将拨号的命令直接定义成了 ADSL_BASH。 随后程序又调用 get_ip() 方法,通过 subprocess 模块执行获取 IP 的命令 ifconfig,然后根据网卡名称获取了当前拨号网卡的 IP 地址,即拨号后的 IP。 再接下来就需要测试代理有效性了。程序首先调用了 test_proxy() 方法,将自身的代理设置好,使用 requests 库来用代理连接 TEST_URL。在此 TEST_URL 设置为百度,如果请求成功,则证明代理有效。 如果代理有效,再调用 set_proxy() 方法将 Redis 散列表中本机对应的代理更新,设置时需要指定本机唯一标识和本机当前代理。本机唯一标识可随意配置,其对应的变量为 CLIENT_NAME,保证各台拨号主机不冲突即可。本机当前代理则由拨号后的新 IP 加端口组合而成。通过调用 RedisClient 的 set() 方法,参数 name 为本机唯一标识,proxy 为拨号后的新代理,执行之后便可以更新散列表中的本机代理了。 建议至少配置两台主机,这样在一台主机的拨号间隙还有另一台主机的代理可用。拨号主机的数量不限,越多越好。 在拨号主机上执行拨号脚本,示例输出如图 9-16 所示。 图 9-16 示例输出 首先移除了代理,再进行拨号,拨号完成之后获取新的 IP,代理检测成功之后就设置到 Redis 散列表中,然后等待一段时间再重新进行拨号。 我们添加了多台拨号主机,这样就有多个稳定的定时更新的代理可用了。Redis 散列表会实时更新各台拨号主机的代理,如图 9-17 所示。 图 9-17 Hash 结构 图中所示是四台 ADSL 拨号主机配置并运行后的散列表的内容,表中的代理都是可用的。

8. 接口模块

目前为止,我们已经成功实时更新拨号主机的代理。不过还缺少一个模块,那就是接口模块。像之前的代理池一样,我们也定义一些接口来获取代理,如 random 获取随机代理、count 获取代理个数等。 我们选用 Tornado 来实现,利用 Tornado 的 Server 模块搭建 Web 接口服务,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import json
import tornado.ioloop
import tornado.web
from tornado.web import RequestHandler, Application

# API 端口
API_PORT = 8000

class MainHandler(RequestHandler):
def initialize(self, redis):
self.redis = redis

def get(self, api=''):
if not api:
links = ['random', 'proxies', 'names', 'all', 'count']
self.write('<h4>Welcome to ADSL Proxy API</h4>')
for link in links:
self.write('<a href=' + link + '>' + link + '</a><br>')

if api == 'random':
result = self.redis.random()
if result:
self.write(result)

if api == 'names':
result = self.redis.names()
if result:
self.write(json.dumps(result))

if api == 'proxies':
result = self.redis.proxies()
if result:
self.write(json.dumps(result))

if api == 'all':
result = self.redis.all()
if result:
self.write(json.dumps(result))

if api == 'count':
self.write(str(self.redis.count()))

def server(redis, port=API_PORT, address=''):
application = Application([(r'/', MainHandler, dict(redis=redis)),
(r'/(.*)', MainHandler, dict(redis=redis)),
])
application.listen(port, address=address)
print('ADSL API Listening on', port)
tornado.ioloop.IOLoop.instance().start()

这里定义了 5 个接口,random 获取随机代理,names 获取主机列表,proxies 获取代理列表,all 获取代理映射,count 获取代理数量。 程序启动之后便会在 API_PORT 端口上运行 Web 服务,主页面如图 9-18 所示。 图 9-18 主页面 访问 proxies 接口可以获得所有代理列表,如图 9-19 所示。 图 9-19 代理列表 访问 random 接口可以获取随机可用代理,如图 9-20 所示。 图 9-20 随机代理 我们只需将接口部署到服务器上,即可通过 Web 接口获取可用代理,获取方式和代理池类似。

9. 本节代码

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

10. 结语

本节介绍了 ADSL 拨号代理的搭建过程。通过这种代理,我们可以无限次更换 IP,而且线路非常稳定,抓取效果好很多。

Python

13.4 Spider 的用法

在 Scrapy 中,要抓取网站的链接配置、抓取逻辑、解析逻辑里其实都是在 Spider 中配置的。在前一节实例中,我们发现抓取逻辑也是在 Spider 中完成的。本节我们就来专门了解一下 Spider 的基本用法。

1. Spider 运行流程

在实现 Scrapy 爬虫项目时,最核心的类便是 Spider 类了,它定义了如何爬取某个网站的流程和解析方式。简单来讲,Spider 要做的事就是如下两件。

  • 定义爬取网站的动作
  • 分析爬取下来的网页

对于 Spider 类来说,整个爬取循环如下所述。

  • 以初始的 URL 初始化 Request,并设置回调函数。 当该 Request 成功请求并返回时,将生成 Response,并作为参数传给该回调函数。
  • 在回调函数内分析返回的网页内容。返回结果可以有两种形式,一种是解析到的有效结果返回字典或 Item 对象。下一步可经过处理后(或直接)保存,另一种是解析得下一个(如下一页)链接,可以利用此链接构造 Request 并设置新的回调函数,返回 Request。
  • 如果返回的是字典或 Item 对象,可通过 Feed Exports 等形式存入到文件,如果设置了 Pipeline 的话,可以经由 Pipeline 处理(如过滤、修正等)并保存。
  • 如果返回的是 Reqeust,那么 Request 执行成功得到 Response 之后会再次传递给 Request 中定义的回调函数,可以再次使用选择器来分析新得到的网页内容,并根据分析的数据生成 Item。

通过以上几步循环往复进行,便完成了站点的爬取。

2. Spider 类分析

在上一节的例子中我们定义的 Spider 是继承自 scrapy.spiders.Spider,这个类是最简单最基本的 Spider 类,每个其他的 Spider 必须继承这个类,还有后文要说明的一些特殊 Spider 类也都是继承自它。 这个类里提供了 start_requests() 方法的默认实现,读取并请求 start_urls 属性,并根据返回的结果调用 parse() 方法解析结果。另外它还有一些基础属性,下面对其进行讲解:

  • name,爬虫名称,是定义 Spider 名字的字符串。Spider 的名字定义了 Scrapy 如何定位并初始化 Spider,所以其必须是唯一的。 不过我们可以生成多个相同的 Spider 实例,这没有任何限制。 name 是 Spider 最重要的属性,而且是必须的。如果该 Spider 爬取单个网站,一个常见的做法是以该网站的域名名称来命名 Spider。 例如,如果 Spider 爬取 mywebsite.com ,该 Spider 通常会被命名为 mywebsite 。
  • allowed_domains,允许爬取的域名,是可选配置,不在此范围的链接不会被跟进爬取。
  • start_urls,起始 URL 列表,当我们没有实现 start_requests() 方法时,默认会从这个列表开始抓取。
  • custom_settings,这是一个字典,是专属于本 Spider 的配置,此设置会覆盖项目全局的设置,而且此设置必须在初始化前被更新,所以它必须定义成类变量。
  • crawler,此属性是由 from_crawler() 方法设置的,代表的是本 Spider 类对应的 Crawler 对象,Crawler 对象中包含了很多项目组件,利用它我们可以获取项目的一些配置信息,如最常见的就是获取项目的设置信息,即 Settings。
  • settings,是一个 Settings 对象,利用它我们可以直接获取项目的全局设置变量。

除了一些基础属性,Spider 还有一些常用的方法,在此介绍如下:

  • start_requests(),此方法用于生成初始请求,它必须返回一个可迭代对象,此方法会默认使用 start_urls 里面的 URL 来构造 Request,而且 Request 是 GET 请求方式。如果我们想在启动时以 POST 方式访问某个站点,可以直接重写这个方法,发送 POST 请求时我们使用 FormRequest 即可。
  • parse(),当 Response 没有指定回调函数时,该方法会默认被调用,它负责处理 Response,处理返回结果,并从中提取出想要的数据和下一步的请求,然后返回。该方法需要返回一个包含 Request 或 Item 的可迭代对象。
  • closed(),当 Spider 关闭时,该方法会被调用,在这里一般会定义释放资源的一些操作或其他收尾操作。

3. 结语

以上的介绍可能初看起来有点摸不清头脑,不过不用担心,后面我们会有很多实例来使用这些属性和方法,慢慢会熟练掌握的。

Python

13.3 Selector 的用法

我们之前介绍了利用 Beautiful Soup、pyquery 以及正则表达式来提取网页数据,这确实非常方便。而 Scrapy 还提供了自己的数据提取方法,即 Selector(选择器)。Selector 是基于 lxml 来构建的,支持 XPath 选择器、CSS 选择器以及正则表达式,功能全面,解析速度和准确度非常高。 本节将介绍 Selector 的用法。

1. 直接使用

Selector 是一个可以独立使用的模块。我们可以直接利用 Selector 这个类来构建一个选择器对象,然后调用它的相关方法如 xpath()、css() 等来提取数据。 例如,针对一段 HTML 代码,我们可以用如下方式构建 Selector 对象来提取数据:

1
2
3
4
5
6
from scrapy import Selector

body = '<html><head><title>Hello World</title></head><body></body></html>'
selector = Selector(text=body)
title = selector.xpath('//title/text()').extract_first()
print(title)

运行结果:

1
Hello World

我们在这里没有在 Scrapy 框架中运行,而是把 Scrapy 中的 Selector 单独拿出来使用了,构建的时候传入 text 参数,就生成了一个 Selector 选择器对象,然后就可以像前面我们所用的 Scrapy 中的解析方式一样,调用 xpath()、css() 等方法来提取了。 在这里我们查找的是源代码中的 title 中的文本,在 XPath 选择器最后加 text() 方法就可以实现文本的提取了。 以上内容就是 Selector 的直接使用方式。同 Beautiful Soup 等库类似,Selector 其实也是强大的网页解析库。如果方便的话,我们也可以在其他项目中直接使用 Selector 来提取数据。 接下来,我们用实例来详细讲解 Selector 的用法。

2. Scrapy Shell

由于 Selector 主要是与 Scrapy 结合使用,如 Scrapy 的回调函数中的参数 response 直接调用 xpath() 或者 css() 方法来提取数据,所以在这里我们借助 Scrapy shell 来模拟 Scrapy 请求的过程,来讲解相关的提取方法。 我们用官方文档的一个样例页面来做演示:http://doc.scrapy.org/en/latest/_static/selectors-sample1.html。 开启 Scrapy shell,在命令行输入如下命令:

1
scrapy shell http://doc.scrapy.org/en/latest/_static/selectors-sample1.html

我们就进入到 Scrapy shell 模式。这个过程其实是,Scrapy 发起了一次请求,请求的 URL 就是刚才命令行下输入的 URL,然后把一些可操作的变量传递给我们,如 request、response 等,如图 13-5 所示。 图 13-5 Scrapy Shell 我们可以在命令行模式下输入命令调用对象的一些操作方法,回车之后实时显示结果。这与 Python 的命令行交互模式是类似的。 接下来,演示的实例都将页面的源码作为分析目标,页面源码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<html>
<head>
<base href='http://example.com/' />
<title>Example website</title>
</head>
<body>
<div id='images'>
<a href='image1.html'>Name: My image 1 <br /><img src='image1_thumb.jpg' /></a>
<a href='image2.html'>Name: My image 2 <br /><img src='image2_thumb.jpg' /></a>
<a href='image3.html'>Name: My image 3 <br /><img src='image3_thumb.jpg' /></a>
<a href='image4.html'>Name: My image 4 <br /><img src='image4_thumb.jpg' /></a>
<a href='image5.html'>Name: My image 5 <br /><img src='image5_thumb.jpg' /></a>
</div>
</body>
</html>

3. XPath 选择器

进入 Scrapy shell 之后,我们将主要操作 response 这个变量来进行解析。因为我们解析的是 HTML 代码,Selector 将自动使用 HTML 语法来分析。 response 有一个属性 selector,我们调用 response.selector 返回的内容就相当于用 response 的 text 构造了一个 Selector 对象。通过这个 Selector 对象我们可以调用解析方法如 xpath()、css() 等,通过向方法传入 XPath 或 CSS 选择器参数就可以实现信息的提取。 我们用一个实例感受一下,如下所示:

1
2
3
4
5
6
7
8
9
\>>> result = response.selector.xpath('//a')
>>> result
[<Selector xpath='//a' data='<a href="image1.html">Name: My image 1 <'>,
<Selector xpath='//a' data='<a href="image2.html">Name: My image 2 <'>,
<Selector xpath='//a' data='<a href="image3.html">Name: My image 3 <'>,
<Selector xpath='//a' data='<a href="image4.html">Name: My image 4 <'>,
<Selector xpath='//a' data='<a href="image5.html">Name: My image 5 <'>]
>>> type(result)
scrapy.selector.unified.SelectorList

打印结果的形式是 Selector 组成的列表,其实它是 SelectorList 类型,SelectorList 和 Selector 都可以继续调用 xpath() 和 css() 等方法来进一步提取数据。 在上面的例子中,我们提取了 a 节点。接下来,我们尝试继续调用 xpath() 方法来提取 a 节点内包含的 img 节点,如下所示:

1
2
3
4
5
6
\>>> result.xpath('./img')
[<Selector xpath='./img' data='<img src="image1_thumb.jpg">'>,
<Selector xpath='./img' data='<img src="image2_thumb.jpg">'>,
<Selector xpath='./img' data='<img src="image3_thumb.jpg">'>,
<Selector xpath='./img' data='<img src="image4_thumb.jpg">'>,
<Selector xpath='./img' data='<img src="image5_thumb.jpg">'>]

我们获得了 a 节点里面的所有 img 节点,结果为 5。 值得注意的是,选择器的最前方加 .(点),这代表提取元素内部的数据,如果没有加点,则代表从根节点开始提取。此处我们用了./img 的提取方式,则代表从 a 节点里进行提取。如果此处我们用 //img,则还是从 html 节点里进行提取。 我们刚才使用了 response.selector.xpath() 方法对数据进行了提取。Scrapy 提供了两个实用的快捷方法,response.xpath() 和 response.css(),它们二者的功能完全等同于 response.selector.xpath() 和 response.selector.css()。方便起见,后面我们统一直接调用 response 的 xpath() 和 css() 方法进行选择。 现在我们得到的是 SelectorList 类型的变量,该变量是由 Selector 对象组成的列表。我们可以用索引单独取出其中某个 Selector 元素,如下所示:

1
2
\>>> result[0]
<Selector xpath='//a' data='<a href="image1.html">Name: My image 1 <'>

我们可以像操作列表一样操作这个 SelectorList。 但是现在获取的内容是 Selector 或者 SelectorList 类型,并不是真正的文本内容。那么具体的内容怎么提取呢? 比如我们现在想提取出 a 节点元素,就可以利用 extract() 方法,如下所示:

1
2
\>>> result.extract()
['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg"></a>', '<a href="image2.html">Name: My image 2 <br><img src="image2_thumb.jpg"></a>', '<a href="image3.html">Name: My image 3 <br><img src="image3_thumb.jpg"></a>', '<a href="image4.html">Name: My image 4 <br><img src="image4_thumb.jpg"></a>', '<a href="image5.html">Name: My image 5 <br><img src="image5_thumb.jpg"></a>']

这里使用了 extract() 方法,我们就可以把真实需要的内容获取下来。 我们还可以改写 XPath 表达式,来选取节点的内部文本和属性,如下所示:

1
2
3
4
\>>> response.xpath('//a/text()').extract()
['Name: My image 1 ', 'Name: My image 2 ', 'Name: My image 3 ', 'Name: My image 4 ', 'Name: My image 5 ']
>>> response.xpath('//a/@href').extract()
['image1.html', 'image2.html', 'image3.html', 'image4.html', 'image5.html']

我们只需要再加一层 /text() 就可以获取节点的内部文本,或者加一层 /@href 就可以获取节点的 href 属性。其中,@符号后面内容就是要获取的属性名称。 现在我们可以用一个规则把所有符合要求的节点都获取下来,返回的类型是列表类型。 但是这里有一个问题:如果符合要求的节点只有一个,那么返回的结果会是什么呢?我们再用一个实例来感受一下,如下所示:

1
2
\>>> response.xpath('//a[@href="image1.html"]/text()').extract()
['Name: My image 1 ']

我们用属性限制了匹配的范围,使 XPath 只可以匹配到一个元素。然后用 extract() 方法提取结果,其结果还是一个列表形式,其文本是列表的第一个元素。但很多情况下,我们其实想要的数据就是第一个元素内容,这里我们通过加一个索引来获取,如下所示: ```python>>> response.xpath(‘//a[@href=”image1.html”]/text()’).extract()[0] ‘Name: My image 1 ‘

1
2
3
4
5
6
7
 但是,这个写法很明显是有风险的。一旦 XPath 有问题,那么 extract() 后的结果可能是一个空列表。如果我们再用索引来获取,那不就会可能导致数组越界吗?

所以,另外一个方法可以专门提取单个元素,它叫作 extract_first()。我们可以改写上面的例子如下所示:

```python
>>> response.xpath('//a[@href="image1.html"]/text()').extract_first()
'Name: My image 1 '

这样,我们直接利用 extract_first() 方法将匹配的第一个结果提取出来,同时我们也不用担心数组越界的问题。 另外我们也可以为 extract_first() 方法设置一个默认值参数,这样当 XPath 规则提取不到内容时会直接使用默认值。例如将 XPath 改成一个不存在的规则,重新执行代码,如下所示:

1
2
\>>> response.xpath('//a[@href="image1"]/text()').extract_first()>>> response.xpath('//a[@href="image1"]/text()').extract_first('Default Image')
'Default Image'

这里,如果 XPath 匹配不到任何元素,调用 extract_first() 会返回空,也不会报错。 在第二行代码中,我们还传递了一个参数当作默认值,如 Default Image。这样如果 XPath 匹配不到结果的话,返回值会使用这个参数来代替,可以看到输出正是如此。 现在为止,我们了解了 Scrapy 中的 XPath 的相关用法,包括嵌套查询、提取内容、提取单个内容、获取文本和属性等。

4. CSS 选择器

接下来,我们看看 CSS 选择器的用法。 Scrapy 的选择器同时还对接了 CSS 选择器,使用 response.css() 方法可以使用 CSS 选择器来选择对应的元素。 例如在上文我们选取了所有的 a 节点,那么 CSS 选择器同样可以做到,如下所示:

1
2
3
4
5
6
\>>> response.css('a')
[<Selector xpath='descendant-or-self::a' data='<a href="image1.html">Name: My image 1 <'>,
<Selector xpath='descendant-or-self::a' data='<a href="image2.html">Name: My image 2 <'>,
<Selector xpath='descendant-or-self::a' data='<a href="image3.html">Name: My image 3 <'>,
<Selector xpath='descendant-or-self::a' data='<a href="image4.html">Name: My image 4 <'>,
<Selector xpath='descendant-or-self::a' data='<a href="image5.html">Name: My image 5 <'>]

同样,调用 extract() 方法就可以提取出节点,如下所示: ```python>>> response.css(‘a’).extract() ‘[Name: My image 1 ‘, ‘Name: My image 2 ‘, ‘Name: My image 3 ‘, ‘Name: My image 4 ‘, ‘Name: My image 5 ‘]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 用法和 XPath 选择是完全一样的。

另外,我们也可以进行属性选择和嵌套选择,如下所示:

```python
>>> response.css('a[href="image1.html"]').extract()
['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg"></a>']
>>> response.css('a[href="image1.html"] img').extract()
['<img src="image1_thumb.jpg">']
​```这里用 [href="image.html"] 限定了 href 属性,可以看到匹配结果就只有一个了。另外如果想查找 a 节点内的 img 节点,只需要再加一个空格和 img 即可。选择器的写法和标准 CSS 选择器写法如出一辙。

我们也可以使用 extract_first() 方法提取列表的第一个元素,如下所示:

​```python
>>> response.css('a[href="image1.html"] img').extract_first()
'<img src="image1_thumb.jpg">'

接下来的两个用法不太一样。节点的内部文本和属性的获取是这样实现的,如下所示:

1
2
3
4
\>>> response.css('a[href="image1.html"]::text').extract_first()
'Name: My image 1 '
>>> response.css('a[href="image1.html"] img::attr(src)').extract_first()
'image1_thumb.jpg'

获取文本和属性需要用::text 和::attr() 的写法。而其他库如 Beautiful Soup 或 pyquery 都有单独的方法。 另外,CSS 选择器和 XPath 选择器一样可以嵌套选择。我们可以先用 XPath 选择器选中所有 a 节点,再利用 CSS 选择器选中 img 节点,再用 XPath 选择器获取属性。我们用一个实例来感受一下,如下所示:

1
2
\>>> response.xpath('//a').css('img').xpath('@src').extract()
['image1_thumb.jpg', 'image2_thumb.jpg', 'image3_thumb.jpg', 'image4_thumb.jpg', 'image5_thumb.jpg']

我们成功获取了所有 img 节点的 src 属性。 因此,我们可以随意使用 xpath() 和 css() 方法二者自由组合实现嵌套查询,二者是完全兼容的。

5. 正则匹配

Scrapy 的选择器还支持正则匹配。比如,在示例的 a 节点中的文本类似于 Name: My image 1,现在我们只想把 Name: 后面的内容提取出来,这时就可以借助 re() 方法,实现如下:

1
2
\>>> response.xpath('//a/text()').re('Name:s(.*)')
['My image 1 ', 'My image 2 ', 'My image 3 ', 'My image 4 ', 'My image 5 ']

我们给 re() 方法传了一个正则表达式,其中 (.*) 就是要匹配的内容,输出的结果就是正则表达式匹配的分组,结果会依次输出。 如果同时存在两个分组,那么结果依然会被按序输出,如下所示:

1
2
\>>> response.xpath('//a/text()').re('(.*?):s(.*)')
['Name', 'My image 1 ', 'Name', 'My image 2 ', 'Name', 'My image 3 ', 'Name', 'My image 4 ', 'Name', 'My image 5 ']

类似 extract_first() 方法,re_first() 方法可以选取列表的第一个元素,用法如下:

1
2
3
4
\>>> response.xpath('//a/text()').re_first('(.*?):s(.*)')
'Name'
>>> response.xpath('//a/text()').re_first('Name:s(.*)')
'My image 1 '

不论正则匹配了几个分组,结果都会等于列表的第一个元素。 值得注意的是,response 对象不能直接调用 re() 和 re_first() 方法。如果想要对全文进行正则匹配,可以先调用 xpath() 方法再正则匹配,如下所示:

1
2
3
4
5
6
7
8
\>>> response.re('Name:s(.*)')
Traceback (most recent call last):
File "<console>", line 1, in <module>
AttributeError: 'HtmlResponse' object has no attribute 're'
>>> response.xpath('.').re('Name:s(.*)<br>')
['My image 1 ', 'My image 2 ', 'My image 3 ', 'My image 4 ', 'My image 5 ']
>>> response.xpath('.').re_first('Name:s(.*)<br>')
'My image 1 '

通过上面的例子,我们可以看到,直接调用 re() 方法会提示没有 re 属性。但是这里首先调用了 xpath(‘.’) 选中全文,然后调用 re() 和 re_first() 方法,就可以进行正则匹配了。

6. 结语

以上内容便是 Scrapy 选择器的用法,它包括两个常用选择器和正则匹配功能。熟练掌握 XPath 语法、CSS 选择器语法、正则表达式语法可以大大提高数据提取效率。

Python

13.2 Scrapy 入门

接下来介绍一个简单的项目,完成一遍 Scrapy 抓取流程。通过这个过程,我们可以对 Scrapy 的基本用法和原理有大体了解。

1. 本节目标

本节要完成的任务如下。

  • 创建一个 Scrapy 项目。
  • 创建一个 Spider 来抓取站点和处理数据。
  • 通过命令行将抓取的内容导出。
  • 将抓取的内容保存到 MongoDB 数据库。

2. 准备工作

我们需要安装好 Scrapy 框架、MongoDB 和 PyMongo 库。如果尚未安装,请参照上一节的安装说明。

3. 创建项目

创建一个 Scrapy 项目,项目文件可以直接用 scrapy 命令生成,命令如下所示:

1
scrapy startproject tutorial

这个命令可以在任意文件夹运行。如果提示权限问题,可以加 sudo 运行该命令。这个命令将会创建一个名为 tutorial 的文件夹,文件夹结构如下所示:

1
2
3
4
5
6
7
8
9
scrapy.cfg     # Scrapy 部署时的配置文件
tutorial # 项目的模块,引入的时候需要从这里引入
__init__.py
items.py # Items 的定义,定义爬取的数据结构
middlewares.py # Middlewares 的定义,定义爬取时的中间件
pipelines.py # Pipelines 的定义,定义数据管道
settings.py # 配置文件
spiders # 放置 Spiders 的文件夹
__init__.py

4. 创建 Spider

Spider 是自己定义的类,Scrapy 用它来从网页里抓取内容,并解析抓取的结果。不过这个类必须继承 Scrapy 提供的 Spider 类 scrapy.Spider,还要定义 Spider 的名称和起始请求,以及怎样处理爬取后的结果的方法。 也可以使用命令行创建一个 Spider。比如要生成 Quotes 这个 Spider,可以执行如下命令:

1
2
cd tutorial
scrapy genspider quotes

进入刚才创建的 tutorial 文件夹,然后执行 genspider 命令。第一个参数是 Spider 的名称,第二个参数是网站域名。执行完毕之后,spiders 文件夹中多了一个 quotes.py,它就是刚刚创建的 Spider,内容如下所示:

1
2
3
4
5
6
7
8
9
import scrapy

class QuotesSpider(scrapy.Spider):
name = "quotes"
allowed_domains = ["quotes.toscrape.com"]
start_urls = ['http://quotes.toscrape.com/']

def parse(self, response):
pass

这里有三个属性 ——name、allowed_domains 和 start_urls,还有一个方法 parse。

  • name,它是每个项目唯一的名字,用来区分不同的 Spider。
  • allowed_domains,它是允许爬取的域名,如果初始或后续的请求链接不是这个域名下的,则请求链接会被过滤掉。
  • start_urls,它包含了 Spider 在启动时爬取的 url 列表,初始请求是由它来定义的。
  • parse,它是 Spider 的一个方法。默认情况下,被调用时 start_urls 里面的链接构成的请求完成下载执行后,返回的响应就会作为唯一的参数传递给这个函数。该方法负责解析返回的响应、提取数据或者进一步生成要处理的请求。

5. 创建 Item

Item 是保存爬取数据的容器,它的使用方法和字典类似。不过,相比字典,Item 多了额外的保护机制,可以避免拼写错误或者定义字段错误。 创建 Item 需要继承 scrapy.Item 类,并且定义类型为 scrapy.Field 的字段。观察目标网站,我们可以获取到的内容有 text、author、tags。 定义 Item,此时将 items.py 修改如下:

1
2
3
4
5
6
7
import scrapy

class QuoteItem(scrapy.Item):

text = scrapy.Field()
author = scrapy.Field()
tags = scrapy.Field()

这里定义了三个字段,将类的名称修改为 QuoteItem,接下来爬取时我们会使用到这个 Item。

6. 解析 Response

前面我们看到,parse() 方法的参数 response 是 start_urls 里面的链接爬取后的结果。所以在 parse() 方法中,我们可以直接对 response 变量包含的内容进行解析,比如浏览请求结果的网页源代码,或者进一步分析源代码内容,或者找出结果中的链接而得到下一个请求。 我们可以看到网页中既有我们想要的结果,又有下一页的链接,这两部分内容我们都要进行处理。 首先看看网页结构,如图 13-2 所示。每一页都有多个 class 为 quote 的区块,每个区块内都包含 text、author、tags。那么我们先找出所有的 quote,然后提取每一个 quote 中的内容。 图 13-2 页面结构 提取的方式可以是 CSS 选择器或 XPath 选择器。在这里我们使用 CSS 选择器进行选择,parse() 方法的改写如下所示:

1
2
3
4
5
6
def parse(self, response):
quotes = response.css('.quote')
for quote in quotes:
text = quote.css('.text::text').extract_first()
author = quote.css('.author::text').extract_first()
tags = quote.css('.tags .tag::text').extract()

这里首先利用选择器选取所有的 quote,并将其赋值为 quotes 变量,然后利用 for 循环对每个 quote 遍历,解析每个 quote 的内容。 对 text 来说,观察到它的 class 为 text,所以可以用.text 选择器来选取,这个结果实际上是整个带有标签的节点,要获取它的正文内容,可以加::text 来获取。这时的结果是长度为 1 的列表,所以还需要用 extract_first() 方法来获取第一个元素。而对于 tags 来说,由于我们要获取所有的标签,所以用 extract() 方法获取整个列表即可。 以第一个 quote 的结果为例,各个选择方法及结果的说明如下内容。 源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div class="quote" itemscope=""itemtype="http://schema.org/CreativeWork">
<span class="text" itemprop="text">“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”</span>
<span>by <small class="author" itemprop="author">Albert Einstein</small>
<a href="/author/Albert-Einstein">(about)</a>
</span>
<div class="tags">
Tags:
<meta class="keywords" itemprop="keywords" content="change,deep-thoughts,thinking,world">
<a class="tag" href="/tag/change/page/1/">change</a>
<a class="tag" href="/tag/deep-thoughts/page/1/">deep-thoughts</a>
<a class="tag" href="/tag/thinking/page/1/">thinking</a>
<a class="tag" href="/tag/world/page/1/">world</a>
</div>
</div>

不同选择器的返回结果如下。

quote.css(‘.text’)

1
[<Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' text ')]"data='<span class="text"itemprop="text">“The '>]

quote.css(‘.text::text’)

1
[<Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' text ')]/text()"data='“The world as we have created it is a pr'>]

quote.css(‘.text’).extract()

1
['<span class="text"itemprop="text">“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”</span>']

quote.css(‘.text::text’).extract()

1
['“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”']

quote.css(‘.text::text’).extract_first()

1
“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”

所以,对于 text,获取结果的第一个元素即可,所以使用 extract_first() 方法,对于 tags,要获取所有结果组成的列表,所以使用 extract() 方法。

7. 使用 Item

上文定义了 Item,接下来就要使用它了。Item 可以理解为一个字典,不过在声明的时候需要实例化。然后依次用刚才解析的结果赋值 Item 的每一个字段,最后将 Item 返回即可。 QuotesSpider 的改写如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import scrapy
from tutorial.items import QuoteItem

class QuotesSpider(scrapy.Spider):
name = "quotes"
allowed_domains = ["quotes.toscrape.com"]
start_urls = ['http://quotes.toscrape.com/']

def parse(self, response):
quotes = response.css('.quote')
for quote in quotes:
item = QuoteItem()
item['text'] = quote.css('.text::text').extract_first()
item['author'] = quote.css('.author::text').extract_first()
item['tags'] = quote.css('.tags .tag::text').extract()
yield item

如此一来,首页的所有内容被解析出来,并被赋值成了一个个 QuoteItem。

8. 后续 Request

上面的操作实现了从初始页面抓取内容。那么,下一页的内容该如何抓取?这就需要我们从当前页面中找到信息来生成下一个请求,然后在下一个请求的页面里找到信息再构造再下一个请求。这样循环往复迭代,从而实现整站的爬取。 将刚才的页面拉到最底部,如图 13-3 所示。 图 13-3 页面底部 有一个 Next 按钮,查看一下源代码,可以发现它的链接是 /page/2/,实际上全链接就是:http://quotes.toscrape.com/page/2,通过这个链接我们就可以构造下一个请求。 构造请求时需要用到 scrapy.Request。这里我们传递两个参数 ——url 和 callback,这两个参数的说明如下。

  • url:它是请求链接。
  • callback:它是回调函数。当指定了该回调函数的请求完成之后,获取到响应,引擎会将该响应作为参数传递给这个回调函数。回调函数进行解析或生成下一个请求,回调函数如上文的 parse() 所示。

由于 parse() 就是解析 text、author、tags 的方法,而下一页的结构和刚才已经解析的页面结构是一样的,所以我们可以再次使用 parse() 方法来做页面解析。 接下来我们要做的就是利用选择器得到下一页链接并生成请求,在 parse() 方法后追加如下的代码:

1
2
3
next = response.css('.pager .next a::attr(href)').extract_first()
url = response.urljoin(next)
yield scrapy.Request(url=url, callback=self.parse)

第一句代码首先通过 CSS 选择器获取下一个页面的链接,即要获取 a 超链接中的 href 属性。这里用到了::attr(href) 操作。然后再调用 extract_first() 方法获取内容。 第二句代码调用了 urljoin() 方法,urljoin() 方法可以将相对 URL 构造成一个绝对的 URL。例如,获取到的下一页地址是 /page/2,urljoin() 方法处理后得到的结果就是:http://quotes.toscrape.com/page/2/。 第三句代码通过 url 和 callback 变量构造了一个新的请求,回调函数 callback 依然使用 parse() 方法。这个请求完成后,响应会重新经过 parse 方法处理,得到第二页的解析结果,然后生成第二页的下一页,也就是第三页的请求。这样爬虫就进入了一个循环,直到最后一页。 通过几行代码,我们就轻松实现了一个抓取循环,将每个页面的结果抓取下来了。 现在,改写之后的整个 Spider 类如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import scrapy
from tutorial.items import QuoteItem

class QuotesSpider(scrapy.Spider):
name = "quotes"
allowed_domains = ["quotes.toscrape.com"]
start_urls = ['http://quotes.toscrape.com/']

def parse(self, response):
quotes = response.css('.quote')
for quote in quotes:
item = QuoteItem()
item['text'] = quote.css('.text::text').extract_first()
item['author'] = quote.css('.author::text').extract_first()
item['tags'] = quote.css('.tags .tag::text').extract()
yield item

next = response.css('.pager .next a::attr("href")').extract_first()
url = response.urljoin(next)
yield scrapy.Request(url=url, callback=self.parse)

9. 运行

接下来,进入目录,运行如下命令:

1
scrapy crawl quotes

就可以看到 Scrapy 的运行结果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
2017-02-19 13:37:20 [scrapy.utils.log] INFO: Scrapy 1.3.0 started (bot: tutorial)
2017-02-19 13:37:20 [scrapy.utils.log] INFO: Overridden settings: {'NEWSPIDER_MODULE': 'tutorial.spiders', 'SPIDER_MODULES': ['tutorial.spiders'], 'ROBOTSTXT_OBEY': True, 'BOT_NAME': 'tutorial'}
2017-02-19 13:37:20 [scrapy.middleware] INFO: Enabled extensions:
['scrapy.extensions.logstats.LogStats',
'scrapy.extensions.telnet.TelnetConsole',
'scrapy.extensions.corestats.CoreStats']
2017-02-19 13:37:20 [scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware',
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware',
'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware',
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware',
'scrapy.downloadermiddlewares.retry.RetryMiddleware',
'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware',
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware',
'scrapy.downloadermiddlewares.redirect.RedirectMiddleware',
'scrapy.downloadermiddlewares.cookies.CookiesMiddleware',
'scrapy.downloadermiddlewares.stats.DownloaderStats']
2017-02-19 13:37:20 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
'scrapy.spidermiddlewares.referer.RefererMiddleware',
'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
'scrapy.spidermiddlewares.depth.DepthMiddleware']
2017-02-19 13:37:20 [scrapy.middleware] INFO: Enabled item pipelines:
[]
2017-02-19 13:37:20 [scrapy.core.engine] INFO: Spider opened
2017-02-19 13:37:20 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2017-02-19 13:37:20 [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023
2017-02-19 13:37:21 [scrapy.core.engine] DEBUG: Crawled (404) <GET http://quotes.toscrape.com/robots.txt> (referer: None)
2017-02-19 13:37:21 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/> (referer: None)
2017-02-19 13:37:21 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/>
{'author': u'Albert Einstein',
'tags': [u'change', u'deep-thoughts', u'thinking', u'world'],
'text': u'u201cThe world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.u201d'}
2017-02-19 13:37:21 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/>
{'author': u'J.K. Rowling',
'tags': [u'abilities', u'choices'],
'text': u'u201cIt is our choices, Harry, that show what we truly are, far more than our abilities.u201d'}
...
2017-02-19 13:37:27 [scrapy.core.engine] INFO: Closing spider (finished)
2017-02-19 13:37:27 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 2859,
'downloader/request_count': 11,
'downloader/request_method_count/GET': 11,
'downloader/response_bytes': 24871,
'downloader/response_count': 11,
'downloader/response_status_count/200': 10,
'downloader/response_status_count/404': 1,
'dupefilter/filtered': 1,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2017, 2, 19, 5, 37, 27, 227438),
'item_scraped_count': 100,
'log_count/DEBUG': 113,
'log_count/INFO': 7,
'request_depth_max': 10,
'response_received_count': 11,
'scheduler/dequeued': 10,
'scheduler/dequeued/memory': 10,
'scheduler/enqueued': 10,
'scheduler/enqueued/memory': 10,
'start_time': datetime.datetime(2017, 2, 19, 5, 37, 20, 321557)}
2017-02-19 13:37:27 [scrapy.core.engine] INFO: Spider closed (finished)

这里只是部分运行结果,中间一些抓取结果已省略。 首先,Scrapy 输出了当前的版本号以及正在启动的项目名称。接着输出了当前 settings.py 中一些重写后的配置。然后输出了当前所应用的 Middlewares 和 Pipelines。Middlewares 默认是启用的,可以在 settings.py 中修改。Pipelines 默认是空,同样也可以在 settings.py 中配置。后面会对它们进行讲解。 接下来就是输出各个页面的抓取结果了,可以看到爬虫一边解析,一边翻页,直至将所有内容抓取完毕,然后终止。 最后,Scrapy 输出了整个抓取过程的统计信息,如请求的字节数、请求次数、响应次数、完成原因等。 整个 Scrapy 程序成功运行。我们通过非常简单的代码就完成了一个网站内容的爬取,这样相比之前一点点写程序简洁很多。

10. 保存到文件

运行完 Scrapy 后,我们只在控制台看到了输出结果。如果想保存结果该怎么办呢? 要完成这个任务其实不需要任何额外的代码,Scrapy 提供的 Feed Exports 可以轻松将抓取结果输出。例如,我们想将上面的结果保存成 JSON 文件,可以执行如下命令:

1
scrapy crawl quotes -o quotes.json

命令运行后,项目内多了一个 quotes.json 文件,文件包含了刚才抓取的所有内容,内容是 JSON 格式。 另外我们还可以每一个 Item 输出一行 JSON,输出后缀为 jl,为 jsonline 的缩写,命令如下所示:

1
scrapy crawl quotes -o quotes.jl

1
scrapy crawl quotes -o quotes.jsonlines

输出格式还支持很多种,例如 csv、xml、pickle、marshal 等,还支持 ftp、s3 等远程输出,另外还可以通过自定义 ItemExporter 来实现其他的输出。 例如,下面命令对应的输出分别为 csv、xml、pickle、marshal 格式以及 ftp 远程输出:

1
2
3
4
5
scrapy crawl quotes -o quotes.csv
scrapy crawl quotes -o quotes.xml
scrapy crawl quotes -o quotes.pickle
scrapy crawl quotes -o quotes.marshal
scrapy crawl quotes -o ftp://user:pass@ftp.example.com/path/to/quotes.csv

其中,ftp 输出需要正确配置用户名、密码、地址、输出路径,否则会报错。 通过 Scrapy 提供的 Feed Exports,我们可以轻松地输出抓取结果到文件。对于一些小型项目来说,这应该足够了。不过如果想要更复杂的输出,如输出到数据库等,我们可以使用 Item Pileline 来完成。

11. 使用 Item Pipeline

如果想进行更复杂的操作,如将结果保存到 MongoDB 数据库,或者筛选某些有用的 Item,则我们可以定义 Item Pipeline 来实现。 Item Pipeline 为项目管道。当 Item 生成后,它会自动被送到 Item Pipeline 进行处理,我们常用 Item Pipeline 来做如下操作。

  • 清洗 HTML 数据
  • 验证爬取数据,检查爬取字段
  • 查重并丢弃重复内容
  • 将爬取结果储存到数据库

要实现 Item Pipeline 很简单,只需要定义一个类并实现 process_item() 方法即可。启用 Item Pipeline 后,Item Pipeline 会自动调用这个方法。process_item() 方法必须返回包含数据的字典或 Item 对象,或者抛出 DropItem 异常。 process_item() 方法有两个参数。一个参数是 item,每次 Spider 生成的 Item 都会作为参数传递过来。另一个参数是 spider,就是 Spider 的实例。 接下来,我们实现一个 Item Pipeline,筛掉 text 长度大于 50 的 Item,并将结果保存到 MongoDB。 修改项目里的 pipelines.py 文件,之前用命令行自动生成的文件内容可以删掉,增加一个 TextPipeline 类,内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
from scrapy.exceptions import DropItem

class TextPipeline(object):
def __init__(self):
self.limit = 50

def process_item(self, item, spider):
if item['text']:
if len(item['text']) > self.limit:
item['text'] = item['text'][0:self.limit].rstrip() + '...'
return item
else:
return DropItem('Missing Text')

这段代码在构造方法里定义了限制长度为 50,实现了 process_item() 方法,其参数是 item 和 spider。首先该方法判断 item 的 text 属性是否存在,如果不存在,则抛出 DropItem 异常;如果存在,再判断长度是否大于 50,如果大于,那就截断然后拼接省略号,再将 item 返回即可。 接下来,我们将处理后的 item 存入 MongoDB,定义另外一个 Pipeline。同样在 pipelines.py 中,我们实现另一个类 MongoPipeline,内容如下所示:

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

class MongoPipeline(object):
def __init__(self, mongo_uri, mongo_db):
self.mongo_uri = mongo_uri
self.mongo_db = mongo_db

@classmethod
def from_crawler(cls, crawler):
return cls(mongo_uri=crawler.settings.get('MONGO_URI'),
mongo_db=crawler.settings.get('MONGO_DB')
)

def open_spider(self, spider):
self.client = pymongo.MongoClient(self.mongo_uri)
self.db = self.client[self.mongo_db]

def process_item(self, item, spider):
name = item.__class__.__name__
self.db[name].insert(dict(item))
return item

def close_spider(self, spider):
self.client.close()

MongoPipeline 类实现了 API 定义的另外几个方法。

  • from_crawler,这是一个类方法,用 @classmethod 标识,是一种依赖注入的方式,方法的参数就是 crawler,通过 crawler 这个我们可以拿到全局配置的每个配置信息,在全局配置 settings.py 中我们可以定义 MONGO_URI 和 MONGO_DB 来指定 MongoDB 连接需要的地址和数据库名称,拿到配置信息之后返回类对象即可。所以这个方法的定义主要是用来获取 settings.py 中的配置的。
  • open_spider,当 Spider 被开启时,这个方法被调用。在这里主要进行了一些初始化操作。
  • close_spider,当 Spider 被关闭时,这个方法会调用,在这里将数据库连接关闭。

最主要的 process_item() 方法则执行了数据插入操作。 定义好 TextPipeline 和 MongoPipeline 这两个类后,我们需要在 settings.py 中使用它们。MongoDB 的连接信息还需要定义。 我们在 settings.py 中加入如下内容:

1
2
3
4
5
6
ITEM_PIPELINES = {
'tutorial.pipelines.TextPipeline': 300,
'tutorial.pipelines.MongoPipeline': 400,
}
MONGO_URI='localhost'
MONGO_DB='tutorial'

赋值 ITEM_PIPELINES 字典,键名是 Pipeline 的类名称,键值是调用优先级,是一个数字,数字越小则对应的 Pipeline 越先被调用。 再重新执行爬取,命令如下所示:

1
scrapy crawl quotes

爬取结束后,MongoDB 中创建了一个 tutorial 的数据库、QuoteItem 的表,如图 13-4 所示。 图 13-4 爬取结果 长的 text 已经被处理并追加了省略号,短的 text 保持不变,author 和 tags 也都相应保存。

12. 源代码

本节代码地址:https://github.com/Python3WebSpider/ScrapyTutorial

13. 结语

我们通过抓取 Quotes 网站完成了整个 Scrapy 的简单入门。但这只是冰山一角,还有很多内容等待我们去探索。

Paper

13.1 Scrapy 框架介绍

Scrapy 是一个基于 Twisted 的异步处理框架,是纯 Python 实现的爬虫框架,其架构清晰,模块之间的耦合程度低,可扩展性极强,可以灵活完成各种需求。我们只需要定制开发几个模块就可以轻松实现一个爬虫。

1. 架构介绍

首先我们来看下 Scrapy 框架的架构,如图 13-1 所示: 图 13-1 Scrapy 架构 它可以分为如下的几个部分。

  • Engine,引擎,用来处理整个系统的数据流处理,触发事务,是整个框架的核心。
  • Item,项目,它定义了爬取结果的数据结构,爬取的数据会被赋值成该对象。
  • Scheduler, 调度器,用来接受引擎发过来的请求并加入队列中,并在引擎再次请求的时候提供给引擎。
  • Downloader,下载器,用于下载网页内容,并将网页内容返回给蜘蛛。
  • Spiders,蜘蛛,其内定义了爬取的逻辑和网页的解析规则,它主要负责解析响应并生成提取结果和新的请求。
  • Item Pipeline,项目管道,负责处理由蜘蛛从网页中抽取的项目,它的主要任务是清洗、验证和存储数据。
  • Downloader Middlewares,下载器中间件,位于引擎和下载器之间的钩子框架,主要是处理引擎与下载器之间的请求及响应。
  • Spider Middlewares, 蜘蛛中间件,位于引擎和蜘蛛之间的钩子框架,主要工作是处理蜘蛛输入的响应和输出的结果及新的请求。

2. 数据流

Scrapy 中的数据流由引擎控制,其过程如下:

  • Engine 首先打开一个网站,找到处理该网站的 Spider 并向该 Spider 请求第一个要爬取的 URL。
  • Engine 从 Spider 中获取到第一个要爬取的 URL 并通过 Scheduler 以 Request 的形式调度。
  • Engine 向 Scheduler 请求下一个要爬取的 URL。
  • Scheduler 返回下一个要爬取的 URL 给 Engine,Engine 将 URL 通过 Downloader Middlewares 转发给 Downloader 下载。
  • 一旦页面下载完毕, Downloader 生成一个该页面的 Response,并将其通过 Downloader Middlewares 发送给 Engine。
  • Engine 从下载器中接收到 Response 并通过 Spider Middlewares 发送给 Spider 处理。
  • Spider 处理 Response 并返回爬取到的 Item 及新的 Request 给 Engine。
  • Engine 将 Spider 返回的 Item 给 Item Pipeline,将新的 Request 给 Scheduler。
  • 重复第二步到最后一步,直到 Scheduler 中没有更多的 Request,Engine 关闭该网站,爬取结束。

通过多个组件的相互协作、不同组件完成工作的不同、组件对异步处理的支持,Scrapy 最大限度地利用了网络带宽,大大提高了数据爬取和处理的效率。

3. 项目结构

Scrapy 框架和 pyspider 不同,它是通过命令行来创建项目的,代码的编写还是需要 IDE。项目创建之后,项目文件结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
scrapy.cfg
project/
__init__.py
items.py
pipelines.py
settings.py
middlewares.py
spiders/
__init__.py
spider1.py
spider2.py
...

在此要将各个文件的功能描述如下:

  • scrapy.cfg:它是 Scrapy 项目的配置文件,其内定义了项目的配置文件路径、部署相关信息等内容。
  • items.py:它定义 Item 数据结构,所有的 Item 的定义都可以放这里。
  • pipelines.py:它定义 Item Pipeline 的实现,所有的 Item Pipeline 的实现都可以放这里。
  • settings.py:它定义项目的全局配置。
  • middlewares.py:它定义 Spider Middlewares 和 Downloader Middlewares 的实现。
  • spiders:其内包含一个个 Spider 的实现,每个 Spider 都有一个文件。

4. 结语

本节介绍了 Scrapy 框架的基本架构、数据流过程以及项目结构。后面我们会详细了解 Scrapy 的用法,感受它的强大。

Python

12.3 pyspider 用法详解

前面我们了解了 pyspider 的基本用法,我们通过非常少的代码和便捷的可视化操作就完成了一个爬虫的编写,本节我们来总结一下它的详细用法。

1. 命令行

上面的实例通过如下命令启动 pyspider:

1
pyspider all

命令行还有很多可配制参数,完整的命令行结构如下所示:

1
pyspider [OPTIONS] COMMAND [ARGS]

其中,OPTIONS 为可选参数,它可以指定如下参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
Options:
-c, --config FILENAME 指定配置文件名称
--logging-config TEXT 日志配置文件名称,默认: pyspider/pyspider/logging.conf
--debug 开启调试模式
--queue-maxsize INTEGER 队列的最大长度
--taskdb TEXT taskdb 的数据库连接字符串,默认: sqlite
--projectdb TEXT projectdb 的数据库连接字符串,默认: sqlite
--resultdb TEXT resultdb 的数据库连接字符串,默认: sqlite
--message-queue TEXT 消息队列连接字符串,默认: multiprocessing.Queue
--phantomjs-proxy TEXT PhantomJS 使用的代理,ip:port 的形式
--data-path TEXT 数据库存放的路径
--version pyspider 的版本
--help 显示帮助信息

例如,-c 可以指定配置文件的名称,这是一个常用的配置,配置文件的样例结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
{
"taskdb": "mysql+taskdb://username:password@host:port/taskdb",
"projectdb": "mysql+projectdb://username:password@host:port/projectdb",
"resultdb": "mysql+resultdb://username:password@host:port/resultdb",
"message_queue": "amqp://username:password@host:port/%2F",
"webui": {
"username": "some_name",
"password": "some_passwd",
"need-auth": true
}
}

如果要配置 pyspider WebUI 的访问认证,可以新建一个 pyspider.json,内容如下所示:

1
2
3
4
5
6
7
{
"webui": {
"username": "root",
"password": "123456",
"need-auth": true
}
}

这样我们通过在启动时指定配置文件来配置 pyspider WebUI 的访问认证,用户名为 root,密码为 123456,命令如下所示:

1
pyspider -c pyspider.json all

运行之后打开:http://localhost:5000/,页面如 12-26 所示: 图 12-26 运行页面 也可以单独运行 pyspider 的某一个组件。 运行 Scheduler 的命令如下所示:

1
pyspider scheduler [OPTIONS]

运行时也可以指定各种配置,参数如下所示:

1
2
3
4
5
6
7
8
9
10
Options:
--xmlrpc /--no-xmlrpc
--xmlrpc-host TEXT
--xmlrpc-port INTEGER
--inqueue-limit INTEGER 任务队列的最大长度,如果满了则新的任务会被忽略
--delete-time INTEGER 设置为 delete 标记之前的删除时间
--active-tasks INTEGER 当前活跃任务数量配置
--loop-limit INTEGER 单轮最多调度的任务数量
--scheduler-cls TEXT Scheduler 使用的类
--help 显示帮助信息

运行 Fetcher 的命令如下所示:

1
pyspider fetcher [OPTIONS]

参数配置如下所示:

1
2
3
4
5
6
7
8
9
10
Options:
--xmlrpc /--no-xmlrpc
--xmlrpc-host TEXT
--xmlrpc-port INTEGER
--poolsize INTEGER 同时请求的个数
--proxy TEXT 使用的代理
--user-agent TEXT 使用的 User-Agent
--timeout TEXT 超时时间
--fetcher-cls TEXT Fetcher 使用的类
--help 显示帮助信息

运行 Processer 的命令如下所示:

1
pyspider processor [OPTIONS]

参数配置如下所示:

1
2
3
Options:
--processor-cls TEXT Processor 使用的类
--help 显示帮助信息

运行 WebUI 的命令如下所示:

1
pyspider webui [OPTIONS]

参数配置如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
Options:
--host TEXT 运行地址
--port INTEGER 运行端口
--cdn TEXT JS 和 CSS 的 CDN 服务器
--scheduler-rpc TEXT Scheduler 的 xmlrpc 路径
--fetcher-rpc TEXT Fetcher 的 xmlrpc 路径
--max-rate FLOAT 每个项目最大的 rate 值
--max-burst FLOAT 每个项目最大的 burst 值
--username TEXT Auth 验证的用户名
--password TEXT Auth 验证的密码
--need-auth 是否需要验证
--webui-instance TEXT 运行时使用的 Flask 应用
--help 显示帮助信息

这里的配置和前面提到的配置文件参数是相同的。如果想要改变 WebUI 的端口为 5001,单独运行如下命令:

1
pyspider webui --port 5001

或者可以将端口配置到 JSON 文件中,配置如下所示:

1
2
3
{
"webui": {"port": 5001}
}

使用如下命令启动同样可以达到相同的效果:

1
pyspider -c pyspider.json webui

这样就可以在 5001 端口上运行 WebUI 了。

2. crawl() 方法

在前面的例子中,我们使用 crawl() 方法实现了新请求的生成,但是只指定了 URL 和 Callback。这里将详细介绍一下 crawl() 方法的参数配置。

url

url 是爬取时的 URL,可以定义为单个 URL 字符串,也可以定义成 URL 列表。

callback

callback 是回调函数,指定了该 URL 对应的响应内容用哪个方法来解析,如下所示:

1
2
def on_start(self):
self.crawl('http://scrapy.org/', callback=self.index_page)

这里指定了 callback 为 index_page,就代表爬取 http://scrapy.org/ 链接得到的响应会用 index_page() 方法来解析。 index_page() 方法的第一个参数是响应对象,如下所示:

1
2
def index_page(self, response):
pass

方法中的 response 参数就是请求上述 URL 得到的响应对象,我们可以直接在 index_page() 方法中实现页面的解析。

age

age 是任务的有效时间。如果某个任务在有效时间内且已经被执行,则它不会重复执行,如下所示:

1
2
3
def on_start(self):
self.crawl('http://www.example.org/', callback=self.callback,
age=10*24*60*60)

或者可以这样设置:

1
2
3
@config(age=10 * 24 * 60 * 60)
def callback(self):
pass

默认的有效时间为 10 天。

priority

priority 是爬取任务的优先级,其值默认是 0,priority 的数值越大,对应的请求会越优先被调度,如下所示:

1
2
3
4
def index_page(self):
self.crawl('http://www.example.org/page.html', callback=self.index_page)
self.crawl('http://www.example.org/233.html', callback=self.detail_page,
priority=1)

第二个任务会优先调用,233.html 这个链接优先爬取。

exetime

exetime 参数可以设置定时任务,其值是时间戳,默认是 0,即代表立即执行,如下所示:

1
2
3
4
import time
def on_start(self):
self.crawl('http://www.example.org/', callback=self.callback,
exetime=time.time()+30*60)

这样该任务会在 30 分钟之后执行。

retries

retries 可以定义重试次数,其值默认是 3。

itag

itag 参数设置判定网页是否发生变化的节点值,在爬取时会判定次当前节点是否和上次爬取到的节点相同。如果节点相同,则证明页面没有更新,就不会重复爬取,如下所示:

1
2
3
4
def index_page(self, response):
for item in response.doc('.item').items():
self.crawl(item.find('a').attr.url, callback=self.detail_page,
itag=item.find('.update-time').text())

在这里设置了更新时间这个节点的值为 itag,在下次爬取时就会首先检测这个值有没有发生变化,如果没有变化,则不再重复爬取,否则执行爬取。

auto_recrawl

当开启时,爬取任务在过期后会重新执行,循环时间即定义的 age 时间长度,如下所示:

1
2
3
def on_start(self):
self.crawl('http://www.example.org/', callback=self.callback,
age=5*60*60, auto_recrawl=True)

这里定义了 age 有效期为 5 小时,设置了 auto_recrawl 为 True,这样任务就会每 5 小时执行一次。

method

method 是 HTTP 请求方式,它默认是 GET。如果想发起 POST 请求,可以将 method 设置为 POST。

params

我们可以方便地使用 params 来定义 GET 请求参数,如下所示:

1
2
3
4
def on_start(self):
self.crawl('http://httpbin.org/get', callback=self.callback,
params={'a': 123, 'b': 'c'})
self.crawl('http://httpbin.org/get?a=123&b=c', callback=self.callback)

这里两个爬取任务是等价的。

data

data 是 POST 表单数据。当请求方式为 POST 时,我们可以通过此参数传递表单数据,如下所示:

1
2
3
def on_start(self):
self.crawl('http://httpbin.org/post', callback=self.callback,
method='POST', data={'a': 123, 'b': 'c'})

files

files 是上传的文件,需要指定文件名,如下所示:

1
2
3
def on_start(self):
self.crawl('http://httpbin.org/post', callback=self.callback,
method='POST', files={field: {filename: 'content'}})

user_agent

user_agent 是爬取使用的 User-Agent。

headers

headers 是爬取时使用的 Headers,即 Request Headers。

cookies

cookies 是爬取时使用的 Cookies,为字典格式。

connect_timeout

connect_timeout 是在初始化连接时的最长等待时间,它默认是 20 秒。

timeout

timeout 是抓取网页时的最长等待时间,它默认是 120 秒。

allow_redirects

allow_redirects 确定是否自动处理重定向,它默认是 True。

validate_cert

validate_cert 确定是否验证证书,此选项对 HTTPS 请求有效,默认是 True。

proxy

proxy 是爬取时使用的代理,它支持用户名密码的配置,格式为 username:password@hostname:port,如下所示:

1
2
def on_start(self):
self.crawl('http://httpbin.org/get', callback=self.callback, proxy='127.0.0.1:9743')

也可以设置 craw_config 来实现全局配置,如下所示:

1
2
class Handler(BaseHandler):
crawl_config = {'proxy': '127.0.0.1:9743'}

fetch_type

fetch_type 开启 PhantomJS 渲染。如果遇到 JavaScript 渲染的页面,指定此字段即可实现 PhantomJS 的对接,pyspider 将会使用 PhantomJS 进行网页的抓取,如下所示:

1
2
def on_start(self):
self.crawl('https://www.taobao.com', callback=self.index_page, fetch_type='js')

这样我们就可以实现淘宝页面的抓取了,得到的结果就是浏览器中看到的效果。

js_script

js_script 是页面加载完毕后执行的 JavaScript 脚本,如下所示:

1
2
3
4
5
6
7
def on_start(self):
self.crawl('http://www.example.org/', callback=self.callback,
fetch_type='js', js_script='''
function() {window.scrollTo(0,document.body.scrollHeight);
return 123;
}
''')

页面加载成功后将执行页面混动的 JavaScript 代码,页面会下拉到最底部。

js_run_at

js_run_at 代表 JavaScript 脚本运行的位置,是在页面节点开头还是结尾,默认是结尾,即 document-end。

js_viewport_width/js_viewport_height

js_viewport_width/js_viewport_height 是 JavaScript 渲染页面时的窗口大小。

load_images

load_images 在加载 JavaScript 页面时确定是否加载图片,它默认是否。

save

save 参数非常有用,可以在不同的方法之间传递参数,如下所示:

1
2
3
4
5
6
def on_start(self):
self.crawl('http://www.example.org/', callback=self.callback,
save={'page': 1})

def callback(self, response):
return response.save['page']

这样,在 on_start() 方法中生成 Request 并传递额外的参数 page,在回调函数里可以通过 response 变量的 save 字段接收到这些参数值。

cancel

cancel 是取消任务,如果一个任务是 ACTIVE 状态的,则需要将 force_update 设置为 True。

force_update

即使任务处于 ACTIVE 状态,那也会强制更新状态。 以上便是 crawl() 方法的参数介绍,更加详细的描述可以参考:http://docs.pyspider.org/en/latest/apis/self.crawl/

3. 任务区分

在 pyspider 判断两个任务是否是重复的是使用的是该任务对应的 URL 的 MD5 值作为任务的唯一 ID,如果 ID 相同,那么两个任务就会判定为相同,其中一个就不会爬取了。很多情况下请求的链接可能是同一个,但是 POST 的参数不同。这时可以重写 task_id() 方法,改变这个 ID 的计算方式来实现不同任务的区分,如下所示:

1
2
3
4
import json
from pyspider.libs.utils import md5string
def get_taskid(self, task):
return md5string(task['url']+json.dumps(task['fetch'].get('data', '')))

这里重写了 get_taskid() 方法,利用 URL 和 POST 的参数来生成 ID。这样一来,即使 URL 相同,但是 POST 的参数不同,两个任务的 ID 就不同,它们就不会被识别成重复任务。

4. 全局配置

pyspider 可以使用 crawl_config 来指定全局的配置,配置中的参数会和 crawl() 方法创建任务时的参数合并。如要全局配置一个 Headers,可以定义如下代码:

1
2
3
4
class Handler(BaseHandler):
crawl_config = {
'headers': {'User-Agent': 'GoogleBot',}
}

5. 定时爬取

我们可以通过 every 属性来设置爬取的时间间隔,如下所示:

1
2
3
4
@every(minutes=24 * 60)
def on_start(self):
for url in urllist:
self.crawl(url, callback=self.index_page)

这里设置了每天执行一次爬取。 在上文中我们提到了任务的有效时间,在有效时间内爬取不会重复。所以要把有效时间设置得比重复时间更短,这样才可以实现定时爬取。 例如,下面的代码就无法做到每天爬取:

1
2
3
4
5
6
7
@every(minutes=24 * 60)
def on_start(self):
self.crawl('http://www.example.org/', callback=self.index_page)

@config(age=10 * 24 * 60 * 60)
def index_page(self):
pass

这里任务的过期时间为 10 天,而自动爬取的时间间隔为 1 天。当第二次尝试重新爬取的时候,pyspider 会监测到此任务尚未过期,便不会执行爬取,所以我们需要将 age 设置得小于定时时间。

6. 项目状态

每个项目都有 6 个状态,分别是 TODO、STOP、CHECKING、DEBUG、RUNNING、PAUSE。

  • TODO:它是项目刚刚被创建还未实现时的状态。
  • STOP:如果想停止某项目的抓取,可以将项目的状态设置为 STOP。
  • CHECKING:正在运行的项目被修改后就会变成 CHECKING 状态,项目在中途出错需要调整的时候会遇到这种情况。
  • DEBUG/RUNNING:这两个状态对项目的运行没有影响,状态设置为任意一个,项目都可以运行,但是可以用二者来区分项目是否已经测试通过。
  • PAUSE:当爬取过程中出现连续多次错误时,项目会自动设置为 PAUSE 状态,并等待一定时间后继续爬取。

7. 抓取进度

在抓取时,可以看到抓取的进度,progress 部分会显示 4 个进度条,如图 12-27 所示。 图 12-27 抓取进度 progress 中的 5m、1h、1d 指的是最近 5 分、1 小时、1 天内的请求情况,all 代表所有的请求情况。 蓝色的请求代表等待被执行的任务,绿色的代表成功的任务,黄色的代表请求失败后等待重试的任务,红色的代表失败次数过多而被忽略的任务,从这里我们可以直观看到爬取的进度和请求情况。

8. 删除项目

pyspider 中没有直接删除项目的选项。如要删除任务,那么将项目的状态设置为 STOP,将分组的名称设置为 delete,等待 24 小时,则项目会自动删除。

9. 结语

以上内容便是 pyspider 的常用用法。如要了解更多,可以参考 pyspider 的官方文档:http://docs.pyspider.org/

Python

12.2 pyspider 的基本使用

本节用一个实例来讲解 pyspider 的基本用法。

1. 本节目标

我们要爬取的目标是去哪儿网的旅游攻略,链接为 http://travel.qunar.com/travelbook/list.htm,我们要将所有攻略的作者、标题、出发日期、人均费用、攻略正文等保存下来,存储到 MongoDB 中。

2. 准备工作

请确保已经安装好了 pyspider 和 PhantomJS,安装好了 MongoDB 并正常运行服务,还需要安装 PyMongo 库,具体安装可以参考第 1 章的说明。

3. 启动 pyspider

执行如下命令启动 pyspider:

1
pyspider all

运行效果如图 12-2 所示。 图 12-2 运行结果 这样可以启动 pyspider 的所有组件,包括 PhantomJS、ResultWorker、Processer、Fetcher、Scheduler、WebUI,这些都是 pyspider 运行必备的组件。最后一行输出提示 WebUI 运行在 5000 端口上。可以打开浏览器,输入链接 http://localhost:5000,这时我们会看到页面,如图 12-3 所示。 图 12-3 WebUI 页面 此页面便是 pyspider 的 WebUI,我们可以用它来管理项目、编写代码、在线调试、监控任务等。

4. 创建项目

新建一个项目,点击右边的 Create 按钮,在弹出的浮窗里输入项目的名称和爬取的链接,再点击 Create 按钮,这样就成功创建了一个项目,如图 12-4 所示。 图 12-4 创建项目 接下来会看到 pyspider 的项目编辑和调试页面,如图 12-5 所示。 图 12-5 调试页面 左侧就是代码的调试页面,点击左侧右上角的 run 单步调试爬虫程序,在左侧下半部分可以预览当前的爬取页面。右侧是代码编辑页面,我们可以直接编辑代码和保存代码,不需要借助于 IDE。 注意右侧,pyspider 已经帮我们生成了一段代码,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pyspider.libs.base_handler import *

class Handler(BaseHandler):
crawl_config = { }

@every(minutes=24 * 60)
def on_start(self):
self.crawl('http://travel.qunar.com/travelbook/list.htm', callback=self.index_page)

@config(age=10 * 24 * 60 * 60)
def index_page(self, response):
for each in response.doc('a[href^="http"]').items():
self.crawl(each.attr.href, callback=self.detail_page)

@config(priority=2)
def detail_page(self, response):
return {
"url": response.url,
"title": response.doc('title').text(),}

这里的 Handler 就是 pyspider 爬虫的主类,我们可以在此处定义爬取、解析、存储的逻辑。整个爬虫的功能只需要一个 Handler 即可完成。 接下来我们可以看到一个 crawl_config 属性。我们可以将本项目的所有爬取配置统一定义到这里,如定义 Headers、设置代理等,配置之后全局生效。 然后,on_start() 方法是爬取入口,初始的爬取请求会在这里产生,该方法通过调用 crawl() 方法即可新建一个爬取请求,第一个参数是爬取的 URL,这里自动替换成我们所定义的 URL。crawl() 方法还有一个参数 callback,它指定了这个页面爬取成功后用哪个方法进行解析,代码中指定为 index_page() 方法,即如果这个 URL 对应的页面爬取成功了,那 Response 将交给 index_page() 方法解析。 index_page() 方法恰好接收这个 Response 参数,Response 对接了 pyquery。我们直接调用 doc() 方法传入相应的 CSS 选择器,就可以像 pyquery 一样解析此页面,代码中默认是 a[href^=”http”],也就是说该方法解析了页面的所有链接,然后将链接遍历,再次调用了 crawl() 方法生成了新的爬取请求,同时再指定了 callback 为 detail_page,意思是说这些页面爬取成功了就调用 detail_page() 方法解析。这里,index_page() 实现了两个功能,一是将爬取的结果进行解析,二是生成新的爬取请求。 detail_page() 同样接收 Response 作为参数。detail_page() 抓取的就是详情页的信息,就不会生成新的请求,只对 Response 对象做解析,解析之后将结果以字典的形式返回。当然我们也可以进行后续处理,如将结果保存到数据库。 接下来,我们改写一下代码来实现攻略的爬取吧。

5. 爬取首页

点击左栏右上角的 run 按钮,即可看到页面下方 follows 便会出现一个标注,其中包含数字 1,这代表有新的爬取请求产生,如图 12-6 所示。 图 12-6 操作示例 左栏左上角会出现当前 run 的配置文件,这里有一个 callback 为 on_start,这说明点击 run 之后实际是执行了 on_start() 方法。在 on_start() 方法中,我们利用 crawl() 方法生成一个爬取请求,那下方 follows 部分的数字 1 就代表了这一个爬取请求。 点击下方的 follows 按钮,即可看到生成的爬取请求的链接。每个链接的右侧还有一个箭头按钮,如图 12-7 所示。 图 12-7 操作示例 点击该箭头,我们就可以对此链接进行爬取,也就是爬取攻略的首页内容,如图 12-8 所示。 图 12-8 爬取结果 上方的 callback 已经变成了 index_page,这就代表当前运行了 index_page() 方法。index_page() 接收到的 response 参数就是刚才生成的第一个爬取请求的 Response 对象。index_page() 方法通过调用 doc() 方法,传入提取所有 a 节点的 CSS 选择器,然后获取 a 节点的属性 href,这样实际上就是获取了第一个爬取页面中的所有链接。然后在 index_page() 方法里遍历了所有链接,同时调用 crawl() 方法,就把这一个个的链接构造成新的爬取请求了。所以最下方 follows 按钮部分有 217 的数字标记,这代表新生成了 217 个爬取请求,同时这些请求的 URL 都呈现在当前页面了。 再点击下方的 web 按钮,即可预览当前爬取结果的页面,如图 12-9 所示。 图 12-9 预览页面 当前看到的页面结果和浏览器看到的几乎是完全一致的,在这里我们可以方便地查看页面请求的结果。 点击 html 按钮即可查看当前页面的源代码,如图 12-10 所示。 图 12-10 页面源码 如果需要分析代码的结构,我们可以直接参考页面源码。 我们刚才在 index_page() 方法中提取了所有的链接并生成了新的爬取请求。但是很明显要爬取的肯定不是所有链接,只需要攻略详情的页面链接就够了,所以我们要修改一下当前 index_page() 里提取链接时的 CSS 选择器。 接下来需要另外一个工具。首先切换到 Web 页面,找到攻略的标题,点击下方的 enable css selector helper,点击标题。这时候我们看到标题外多了一个红框,上方出现了一个 CSS 选择器,这就是当前标题对应的 CSS 选择器,如图 12-11 所示。 图 12-11 CSS 工具 在右侧代码选中要更改的区域,点击左栏的右箭头,此时在上方出现的标题的 CSS 选择器就会被替换到右侧代码中,如图 12-12 所示。 图 12-12 操作结果 这样就成功完成了 CSS 选择器的替换,非常便捷。 重新点击左栏右上角的 run 按钮,即可重新执行 index_page() 方法。此时的 follows 就变成了 10 个,也就是说现在我们提取的只有当前页面的 10 个攻略,如图 12-13 所示。 图 12-13 运行结果 我们现在抓取的只是第一页的内容,还需要抓取后续页面,所以还需要一个爬取链接,即爬取下一页的攻略列表页面。我们再利用 crawl() 方法添加下一页的爬取请求,在 index_page() 方法里面添加如下代码,然后点击 save 保存:

1
2
next = response.doc('.next').attr.href
self.crawl(next, callback=self.index_page)

利用 CSS 选择器选中下一页的链接,获取它的 href 属性,也就获取了页面的 URL。然后将该 URL 传给 crawl() 方法,同时指定回调函数,注意这里回调函数仍然指定为 index_page() 方法,因为下一页的结构与此页相同。 重新点击 run 按钮,这时就可以看到 11 个爬取请求。follows 按钮上会显示 11,这就代表我们成功添加了下一页的爬取请求,如图 12-14 所示。 图 12-14 运行结果 现在,索引列表页的解析过程我们就完成了。

6. 爬取详情页

任意选取一个详情页进入,点击前 10 个爬取请求中的任意一个的右箭头,执行详情页的爬取,如图 12-15 所示。 图 12-15 运行结果 切换到 Web 页面预览效果,页面下拉之后,头图正文中的一些图片一直显示加载中,如图 12-16 和图 12-17 所示。 图 12-16 预览结果 图 12-17 预览结果 查看源代码,我们没有看到 img 节点,如图 12-18 所示。 图 12-18 源代码 出现此现象的原因是 pyspider 默认发送 HTTP 请求,请求的 HTML 文档本身就不包含 img 节点。但是在浏览器中我们看到了图片,这是因为这张图片是后期经过 JavaScript 出现的。那么,我们该如何获取呢? 幸运的是,pyspider 内部对接了 PhantomJS,那么我们只需要修改一个参数即可。 我们将 index_page() 中生成抓取详情页的请求方法添加一个参数 fetch_type,改写的 index_page() 变为如下内容:

1
2
3
4
5
def index_page(self, response):
for each in response.doc('li> .tit > a').items():
self.crawl(each.attr.href, callback=self.detail_page, fetch_type='js')
next = response.doc('.next').attr.href
self.crawl(next, callback=self.index_page)

接下来,我们来试试它的抓取效果。 点击左栏上方的左箭头返回,重新调用 index_page() 方法生成新的爬取详情页的 Request,如图 12-19 所示。 图 12-19 爬取详情 再点击新生成的详情页 Request 的爬取按钮,这时我们便可以看到页面变成了这样子,如图 12-20 所示。 图 12-20 运行结果 图片被成功渲染出来,这就是启用了 PhantomJS 渲染后的结果。只需要添加一个 fetch_type 参数即可,这非常方便。 最后就是将详情页中需要的信息提取出来,提取过程不再赘述。最终 detail_page() 方法改写如下所示:

1
2
3
4
5
6
7
8
9
10
def detail_page(self, response):
return {
'url': response.url,
'title': response.doc('#booktitle').text(),
'date': response.doc('.when .data').text(),
'day': response.doc('.howlong .data').text(),
'who': response.doc('.who .data').text(),
'text': response.doc('#b_panel_schedule').text(),
'image': response.doc('.cover_img').attr.src
}

我们分别提取了页面的链接、标题、出行日期、出行天数、人物、攻略正文、头图信息,将这些信息构造成一个字典。 重新运行,即可发现输出结果如图 12-21 所示。 图 12-21 输出结果 左栏中输出了最终构造的字典信息,这就是一篇攻略的抓取结果。

7. 启动爬虫

返回爬虫的主页面,将爬虫的 status 设置成 DEBUG 或 RUNNING,点击右侧的 Run 按钮即可开始爬取,如图 12-22 所示。 图 12-22 启动爬虫 在最左侧我们可以定义项目的分组,以方便管理。rate/burst 代表当前的爬取速率,rate 代表 1 秒发出多少个请求,burst 相当于流量控制中的令牌桶算法的令牌数,rate 和 burst 设置的越大,爬取速率越快,当然速率需要考虑本机性能和爬取过快被封的问题。process 中的 5m、1h、1d 指的是最近 5 分、1 小时、1 天内的请求情况,all 代表所有的请求情况。请求由不同颜色表示,蓝色的代表等待被执行的请求,绿色的代表成功的请求,黄色的代表请求失败后等待重试的请求,红色的代表失败次数过多而被忽略的请求,这样可以直观知道爬取的进度和请求情况,如图 12-23 所示。 图 12-23 爬取情况 点击 Active Tasks,即可查看最近请求的详细状况,如图 12-24 所示。 图 12-24 最近请求 点击 Results,即可查看所有的爬取结果,如图 12-25 所示。 图 12-25 爬取结果 点击右上角的按钮,即可获取数据的 JSON、CSV 格式。

8. 本节代码

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

9. 结语

本节介绍了 pyspider 的基本用法,接下来我们会更加深入了解它的详细使用。

Python

12.1 pyspider 框架介绍

pyspider 是由国人 binux 编写的强大的网络爬虫系统,其 GitHub 地址为 https://github.com/binux/pyspider,官方文档地址为 http://docs.pyspider.org/。 pyspider 带有强大的 WebUI、脚本编辑器、任务监控器、项目管理器以及结果处理器,它支持多种数据库后端、多种消息队列、JavaScript 渲染页面的爬取,使用起来非常方便。

1. pyspider 基本功能

我们总结了一下,PySpider 的功能有如下几点。

  • 提供方便易用的 WebUI 系统,可以可视化地编写和调试爬虫。
  • 提供爬取进度监控、爬取结果查看、爬虫项目管理等功能。
  • 支持多种后端数据库,如 MySQL、MongoDB、Redis、SQLite、Elasticsearch、PostgreSQL。
  • 支持多种消息队列,如 RabbitMQ、Beanstalk、Redis、Kombu。
  • 提供优先级控制、失败重试、定时抓取等功能。
  • 对接了 PhantomJS,可以抓取 JavaScript 渲染的页面。
  • 支持单机和分布式部署,支持 Docker 部署。

如果想要快速方便地实现一个页面的抓取,使用 pyspider 不失为一个好的选择。

2. 与 Scrapy 的比较

后面会介绍另外一个爬虫框架 Scrapy,我们学习完 Scrapy 之后会更容易理解此部分内容。我们先了解一下 pyspider 与 Scrapy 的区别。

  • pyspider 提供了 WebUI,爬虫的编写、调试都是在 WebUI 中进行的,而 Scrapy 原生是不具备这个功能的,采用的是代码和命令行操作,但可以通过对接 Portia 实现可视化配置。
  • pyspider 调试非常方便,WebUI 操作便捷直观,在 Scrapy 中则是使用 parse 命令进行调试,论方便程度不及 pyspider。
  • pyspider 支持 PhantomJS 来进行 JavaScript 渲染页面的采集,在 Scrapy 中可以对接 ScrapySplash 组件,需要额外配置。
  • PySpide r 中内置了 PyQuery 作为选择器,在 Scrapy 中对接了 XPath、CSS 选择器和正则匹配。
  • pyspider 的可扩展程度不足,可配制化程度不高,在 Scrapy 中可以通过对接 Middleware、Pipeline、Extension 等组件实现非常强大的功能,模块之间的耦合程度低,可扩展程度极高。

如果要快速实现一个页面的抓取,推荐使用 pyspider,开发更加便捷,如快速抓取某个普通新闻网站的新闻内容。如果要应对反爬程度很强、超大规模的抓取,推荐使用 Scrapy,如抓取封 IP、封账号、高频验证的网站的大规模数据采集。

3. pyspider 的架构

pyspider 的架构主要分为 Scheduler(调度器)、Fetcher(抓取器)、Processer(处理器)三个部分,整个爬取过程受到 Monitor(监控器)的监控,抓取的结果被 Result Worker(结果处理器)处理,如图 12-1 所示。 图 12-1 pyspider 架构图 Scheduler 发起任务调度,Fetcher 负责抓取网页内容,Processer 负责解析网页内容,然后将新生成的 Request 发给 Scheduler 进行调度,将生成的提取结果输出保存。 pyspider 的任务执行流程的逻辑很清晰,具体过程如下所示。

  • 每个 pyspider 的项目对应一个 Python 脚本,该脚本中定义了一个 Handler 类,它有一个 on_start() 方法。爬取首先调用 on_start() 方法生成最初的抓取任务,然后发送给 Scheduler 进行调度。
  • Scheduler 将抓取任务分发给 Fetcher 进行抓取,Fetcher 执行并得到响应,随后将响应发送给 Processer。
  • Processer 处理响应并提取出新的 URL 生成新的抓取任务,然后通过消息队列的方式通知 Schduler 当前抓取任务执行情况,并将新生成的抓取任务发送给 Scheduler。如果生成了新的提取结果,则将其发送到结果队列等待 Result Worker 处理。
  • Scheduler 接收到新的抓取任务,然后查询数据库,判断其如果是新的抓取任务或者是需要重试的任务就继续进行调度,然后将其发送回 Fetcher 进行抓取。
  • 不断重复以上工作,直到所有的任务都执行完毕,抓取结束。
  • 抓取结束后,程序会回调 on_finished() 方法,这里可以定义后处理过程。

4. 结语

本节我们主要了解了 pyspider 的基本功能和架构。接下来我们会用实例来体验一下 pyspider 的抓取操作,然后总结它的各种用法。

Python

11.6 Appium+mitmdump 爬取京东商品

在前文中,我们曾经用 Charles 分析过京东商品的评论数据,但是可以发现其参数相当复杂,Form 表单有很多加密参数。如果我们只用 Charles 探测到这个接口链接和参数,还是无法直接构造请求的参数,构造的过程涉及一些加密算法,也就无法直接还原抓取过程。

我们了解了 mitmproxy 的用法,利用它的 mitmdump 组件,可以直接对接 Python 脚本对抓取的数据包进行处理,用 Python 脚本对请求和响应直接进行处理。这样我们可以绕过请求的参数构造过程,直接监听响应进行处理即可。但是这个过程并不是自动化的,抓取 App 的时候实际是人工模拟了这个拖动过程。如果这个操作可以用程序来实现就更好了。

我们又了解了 Appium 的用法,它可以指定自动化脚本模拟实现 App 的一系列动作,如点击、拖动等,也可以提取 App 中呈现的信息。经过上节爬取微信朋友圈的实例,我们知道解析过程比较烦琐,而且速度要加以限制。如果内容没有显示出来解析就会失败,而且还会导致重复提取的问题。更重要的是,它只可以获取在 App 中看到的信息,无法直接提取接口获取的真实数据,而接口的数据往往是最易提取且信息量最全的。

综合以上几点,我们就可以确定出一个解决方案了。如果我们用 mitmdump 去监听接口数据,用 Appium 去模拟 App 的操作,就可以绕过复杂的接口参数又可以实现自动化抓取了!这种方式应是抓取 App 数据的最佳方式。某些特殊情况除外,如微信朋友圈数据又经过了一次加密无法解析,而只能用 Appium 提取。但是对于大多数 App 来说,此种方法是奏效的。本节我们用一个实例感受一下这种抓取方式的便捷之处。

1. 本节目标

以抓取京东 App 的商品信息和评论为例,实现 Appium 和 mitmdump 二者结合的抓取。抓取的数据分为两部分:一部分是商品信息,我们需要获取商品的 ID、名称和图片,将它们组成一条商品数据;另一部分是商品的评论信息,我们将评论人的昵称、评论正文、评论日期、发表图片都提取,然后加入商品 ID 字段,将它们组成一条评论数据。最后数据保存到 MongoDB 数据库。

2. 准备工作

请确保 PC 已经安装好 Charles、mitmdump、Appium、Android 开发环境,以及 Python 版本的 Appium API。Android 手机安装好京东 App。另外,安装好 MongoDB 并运行其服务,安装 PyMongo 库。具体的配置过程可以参考第 1 章。

3. Charles 抓包分析

首先,我们将手机代理设置到 Charles 上,用 Charles 抓包分析获取商品详情和商品评论的接口。

获取商品详情的接口,这里提取到的接口是来自 cdnware.m.jd.com 的链接,返回结果是一个 JSON 字符串,里面包含了商品的 ID 和商品名称,如图 11-47 和图 11-48 所示。

图 11-47 请求概览

图 11-48 响应结果

再获取商品评论的接口,这个过程在前文已提到,在此不再赘述。这个接口来自 api.m.jd.com,返回结果也是 JSON 字符串,里面包含了商品的数条评论信息。

之后我们可以用 mitmdump 对接一个 Python 脚本来实现数据的抓取。

4. mitmdump 抓取

新建一个脚本文件,然后实现这个脚本以提取这两个接口的数据。首先提取商品的信息,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
def response(flow):
url = 'cdnware.m.jd.com'
if url in flow.request.url:
text = flow.response.text
data = json.loads(text)
if data.get('wareInfo') and data.get('wareInfo').get('basicInfo'):
info = data.get('wareInfo').get('basicInfo')
id = info.get('wareId')
name = info.get('name')
images = info.get('wareImage')
print(id, name, images)

这里声明了接口的部分链接内容,然后与请求的 URL 作比较。如果该链接出现在当前的 URL 中,那就证明当前的响应就是商品详情的响应,然后提取对应的 JSON 信息即可。在这里我们将商品的 ID、名称和图片提取出来,这就是一条商品数据。

再提取评论的数据,代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 提取评论数据
url = 'api.m.jd.com/client.action'
if url in flow.request.url:
pattern = re.compile('sku".*?"(d+)"')
# Request 请求参数中包含商品 ID
body = unquote(flow.request.text)
# 提取商品 ID
id = re.search(pattern, body).group(1) if re.search(pattern, body) else None
# 提取 Response Body
text = flow.response.text
data = json.loads(text)
comments = data.get('commentInfoList') or []
# 提取评论数据
for comment in comments:
if comment.get('commentInfo') and comment.get('commentInfo').get('commentData'):
info = comment.get('commentInfo')
text = info.get('commentData')
date = info.get('commentDate')
nickname = info.get('userNickName')
pictures = info.get('pictureInfoList')
print(id, nickname, text, date, pictures)

这里指定了接口的部分链接内容,以判断当前请求的 URL 是不是获取评论的 URL。如果满足条件,那么就提取商品的 ID 和评论信息。

商品的 ID 实际上隐藏在请求中,我们需要提取请求的表单内容来提取商品的 ID,这里直接用了正则表达式。

商品的评论信息在响应中,我们像刚才一样提取了响应的内容,然后对 JSON 进行解析,最后提取出商品评论人的昵称、评论正文、评论日期和图片信息。这些信息和商品的 ID 组合起来,形成一条评论数据。

最后用 MongoDB 将两部分数据分开保存到两个 Collection,在此不再赘述。

运行此脚本,命令如下所示:

1
mitmdump -s script.py

手机的代理设置到 mitmdump 上。我们在京东 App 中打开某个商品,下拉商品评论部分,即可看到控制台输出两部分的抓取结果,结果成功保存到 MongoDB 数据库,如图 11-49 所示。

图 11-49 保存结果

如果我们手动操作京东 App 就可以做到京东商品评论的抓取了,下一步要做的就是实现自动滚动刷新。

5. Appium 自动化

将 Appium 对接到手机上,用 Appium 驱动 App 完成一系列动作。进入 App 后,我们需要做的操作有点击搜索框、输入搜索的商品名称、点击进入商品详情、进入评论页面、自动滚动刷新,基本的操作逻辑和爬取微信朋友圈的相同。

京东 App 的 Desired Capabilities 配置如下所示:

1
2
3
4
5
6
{
'platformName': 'Android',
'deviceName': 'MI_NOTE_Pro',
'appPackage': 'com.jingdong.app.mall',
'appActivity': 'main.MainActivity'
}

首先用 Appium 内置的驱动打开京东 App,如图 11-50 所示。

图 11-50 调试界面

这里进行一系动作操作并录制下来,找到各个页面的组件的 ID 并做好记录,最后再改写成完整的代码。参考代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from appium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from time import sleep

class Action():
def __init__(self):
# 驱动配置
self.desired_caps = {
'platformName': PLATFORM,
'deviceName': DEVICE_NAME,
'appPackage': 'com.jingdong.app.mall',
'appActivity': 'main.MainActivity'
}
self.driver = webdriver.Remote(DRIVER_SERVER, self.desired_caps)
self.wait = WebDriverWait(self.driver, TIMEOUT)

def comments(self):
# 点击进入搜索页面
search = self.wait.until(EC.presence_of_element_located((By.ID, 'com.jingdong.app.mall:id/mp')))
search.click()
# 输入搜索文本
box = self.wait.until(EC.presence_of_element_located((By.ID, 'com.jd.lib.search:id/search_box_layout')))
box.set_text(KEYWORD)
# 点击搜索按钮
button = self.wait.until(EC.presence_of_element_located((By.ID, 'com.jd.lib.search:id/search_btn')))
button.click()
# 点击进入商品详情
view = self.wait.until(EC.presence_of_element_located((By.ID, 'com.jd.lib.search:id/product_list_item')))
view.click()
# 进入评论详情
tab = self.wait.until(EC.presence_of_element_located((By.ID, 'com.jd.lib.productdetail:id/pd_tab3')))
tab.click()

def scroll(self):
while True:
# 模拟拖动
self.driver.swipe(FLICK_START_X, FLICK_START_Y + FLICK_DISTANCE, FLICK_START_X, FLICK_START_Y)
sleep(SCROLL_SLEEP_TIME)

def main(self):
self.comments()
self.scroll()

if __name__ == '__main__':
action = Action()
action.main()

代码实现比较简单,逻辑与上一节微信朋友圈的抓取类似。注意,由于 App 版本更新的原因,交互流程和元素 ID 可能有更改,这里的代码仅做参考。

下拉过程已经省去了用 Appium 提取数据的过程,因为这个过程我们已经用 mitmdump 帮助实现了。

代码运行之后便会启动京东 App,进入商品的详情页,然后进入评论页再无限滚动,这样就代替了人工操作。Appium 实现模拟滚动,mitmdump 进行抓取,这样 App 的数据就会保存到数据库中。

6. 本节代码

本节代码地址:https://github.com/Python3WebSpider/MitmAppiumJD

7. 结语

以上内容便是 Appium 和 mitmdump 抓取京东 App 数据的过程。有了两者的配合,我们既可以做到实时数据处理,又可以实现自动化爬取,这样就可以完成绝大多数 App 的爬取了。

Python

11.5 Appium 爬取微信朋友圈

接下来,我们将实现微信朋友圈的爬取。

如果直接用 Charles 或 mitmproxy 来监听微信朋友圈的接口数据,这是无法实现爬取的,因为数据都是被加密的。而 Appium 不同,Appium 作为一个自动化测试工具可以直接模拟 App 的操作并可以获取当前所见的内容。所以只要 App 显示了内容,我们就可以用 Appium 抓取下来。

1. 本节目标

本节我们以 Android 平台为例,实现抓取微信朋友圈的动态信息。动态信息包括好友昵称、正文、发布日期。其中发布日期还需要进行转换,如日期显示为 1 小时前,则时间转换为今天,最后动态信息保存到 MongoDB。

2. 准备工作

请确保 PC 已经安装好 Appium、Android 开发环境和 Python 版本的 Appium API。Android 手机安装好微信 App、PyMongo 库,安装 MongoDB 并运行其服务,安装方法可以参考第 1 章。

3. 初始化

首先新建一个 Moments 类,进行一些初始化配置,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
PLATFORM = 'Android'
DEVICE_NAME = 'MI_NOTE_Pro'
APP_PACKAGE = 'com.tencent.mm'
APP_ACTIVITY = '.ui.LauncherUI'
DRIVER_SERVER = 'http://localhost:4723/wd/hub'
TIMEOUT = 300
MONGO_URL = 'localhost'
MONGO_DB = 'moments'
MONGO_COLLECTION = 'moments'

class Moments():
def __init__(self):
"""初始化"""
# 驱动配置
self.desired_caps = {
'platformName': PLATFORM,
'deviceName': DEVICE_NAME,
'appPackage': APP_PACKAGE,
'appActivity': APP_ACTIVITY
}
self.driver = webdriver.Remote(DRIVER_SERVER, self.desired_caps)
self.wait = WebDriverWait(self.driver, TIMEOUT)
self.client = MongoClient(MONGO_URL)
self.db = self.client[MONGO_DB]
self.collection = self.db[MONGO_COLLECTION]

这里实现了一些初始化配置,如驱动的配置、延时等待配置、MongoDB 连接配置等。

4. 模拟登录

接下来要做的就是登录微信。点击登录按钮,输入用户名、密码,提交登录即可。实现样例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def login(self):
# 登录按钮
login = self.wait.until(EC.presence_of_element_located((By.ID, 'com.tencent.mm:id/cjk')))
login.click()
# 手机输入
phone = self.wait.until(EC.presence_of_element_located((By.ID, 'com.tencent.mm:id/h2')))
phone.set_text(USERNAME)
# 下一步
next = self.wait.until(EC.element_to_be_clickable((By.ID, 'com.tencent.mm:id/adj')))
next.click()
# 密码
password = self.wait.until(EC.presence_of_element_located((By.XPATH, '//*[@resource-id="com.tencent.mm:id/h2"][1]')))
password.set_text(PASSWORD)
# 提交
submit = self.wait.until(EC.element_to_be_clickable((By.ID, 'com.tencent.mm:id/adj')))
submit.click()

这里依次实现了一些点击和输入操作,思路比较简单。对于不同的平台和版本来说,流程可能不太一致,这里仅作参考。

登录完成之后,进入朋友圈的页面。选中朋友圈所在的选项卡,点击朋友圈按钮,即可进入朋友圈,代码实现如下所示:

1
2
3
4
5
6
7
def enter(self):
# 选项卡
tab = self.wait.until(EC.presence_of_element_located((By.XPATH, '//*[@resource-id="com.tencent.mm:id/bw3"][3]')))
tab.click()
# 朋友圈
moments = self.wait.until(EC.presence_of_element_located((By.ID, 'com.tencent.mm:id/atz')))
moments.click()

抓取工作正式开始。

5. 抓取动态

我们知道朋友圈可以一直拖动、不断刷新,所以这里需要模拟一个无限拖动的操作,如下所示:

1
2
3
4
5
6
7
8
9
# 滑动点
FLICK_START_X = 300
FLICK_START_Y = 300
FLICK_DISTANCE = 700

def crawl(self):
while True:
# 上滑
self.driver.swipe(FLICK_START_X, FLICK_START_Y + FLICK_DISTANCE, FLICK_START_X, FLICK_START_Y)

我们利用 swipe() 方法,传入起始和终止点实现拖动,加入无限循环实现无限拖动。

获取当前显示的朋友圈的每条状态对应的区块元素,遍历每个区块元素,再获取内部显示的用户名、正文和发布时间,代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 当前页面显示的所有状态
items = self.wait.until(
EC.presence_of_all_elements_located((By.XPATH, '//*[@resource-id="com.tencent.mm:id/cve"]//android.widget.FrameLayout')))
# 遍历每条状态
for item in items:
try:
# 昵称
nickname = item.find_element_by_id('com.tencent.mm:id/aig').get_attribute('text')
# 正文
content = item.find_element_by_id('com.tencent.mm:id/cwm').get_attribute('text')
# 日期
date = item.find_element_by_id('com.tencent.mm:id/crh').get_attribute('text')
# 处理日期
date = self.processor.date(date)
print(nickname, content, date)
data = {
'nickname': nickname,
'content': content,
'date': date,
}
except NoSuchElementException:
pass

这里遍历每条状态,再调用 find_element_by_id() 方法获取昵称、正文、发布日期对应的元素,然后通过 get_attribute() 方法获取内容。这样我们就成功获取到朋友圈的每条动态信息。

针对日期的处理,我们调用了一个 Processor 类的 date() 处理方法,该方法实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def date(self, datetime):
"""
处理时间
:param datetime: 原始时间
:return: 处理后时间
"""
if re.match('d + 分钟前 ', datetime):
minute = re.match('(d+)', datetime).group(1)
datetime = time.strftime('% Y-% m-% d', time.localtime(time.time() - float(minute) * 60))
if re.match('d + 小时前 ', datetime):
hour = re.match('(d+)', datetime).group(1)
datetime = time.strftime('% Y-% m-% d', time.localtime(time.time() - float(hour) * 60 * 60))
if re.match(' 昨天 ', datetime):
datetime = time.strftime('% Y-% m-% d', time.localtime(time.time() - 24 * 60 * 60))
if re.match('d + 天前 ', datetime):
day = re.match('(d+)', datetime).group(1)
datetime = time.strftime('% Y-% m-% d', time.localtime(time.time()) - float(day) * 24 * 60 * 60)
return datetime

这个方法使用了正则匹配的方法来提取时间中的具体数值,再利用时间转换函数实现时间的转换。例如时间是 5 分钟前,这个方法先将 5 提取出来,用当前时间戳减去 300 即可得到发布时间的时间戳,然后再转化为标准时间即可。

最后调用 MongoDB 的 API 来实现爬取结果的存储。为了去除重复,这里调用了 update() 方法,实现如下所示:

1
self.collection.update({'nickname': nickname, 'content': content}, {'$set': data}, True)

首先根据昵称和正文来查询信息,如果信息不存在,则插入数据,否则更新数据。这个操作的关键点是第三个参数 True,此参数设置为 True,这可以实现存在即更新、不存在则插入的操作。

最后实现一个入口方法调用以上的几个方法。调用此方法即可开始爬取,代码实现如下所示:

1
2
3
4
5
6
7
def main(self):
# 登录
self.login()
# 进入朋友圈
self.enter()
# 爬取
self.crawl()

这样我们就完成了整个朋友圈的爬虫。代码运行之后,手机微信便会启动,并且可以成功进入到朋友圈然后一直不断执行拖动过程。控制台输出相应的爬取结果,结果被成功保存到 MongoDB 数据库中。

6. 结果查看

我们到 MongoDB 中查看爬取结果,如图 11-46 所示。

可以看到朋友圈的数据就成功保存到了数据库。

7. 本节代码

本节源代码地址为:https://github.com/Python3WebSpider/Moments

8. 结语

以上内容是利用 Appium 爬取微信朋友圈的过程。利用 Appium,我们可以做到 App 的可见即可爬,也可以实现自动化驱动和数据爬取。但是实际运行之后,Appium 的解析比较烦琐,而且容易发生重复和中断。如果我们可以用前文所说的 mitmdump 来监听 App 数据实时处理,而 Appium 只负责自动化驱动,它们各负其责,那么整个爬取效率和解析效率就会高很多。所以下一节我们会了解,将 mitmdump 和 Appium 结合起来爬取京东商品的过程。

Python

11.4 Appium 的基本使用

Appium 是一个跨平台移动端自动化测试工具,可以非常便捷地为 iOS 和 Android 平台创建自动化测试用例。它可以模拟 App 内部的各种操作,如点击、滑动、文本输入等,只要我们手工操作的动作 Appium 都可以完成。在前面我们了解过 Selenium,它是一个网页端的自动化测试工具。Appium 实际上继承了 Selenium,Appium 也是利用 WebDriver 来实现 App 的自动化测试。对 iOS 设备来说,Appium 使用 UIAutomation 来实现驱动。对于 Android 来说,它使用 UiAutomator 和 Selendroid 来实现驱动。

Appium 相当于一个服务器,我们可以向 Appium 发送一些操作指令,Appium 就会根据不同的指令对移动设备进行驱动,完成不同的动作。

对于爬虫来说,我们用 Selenium 来抓取 JavaScript 渲染的页面,可见即可爬。Appium 同样也可以,用 Appium 来做 App 爬虫不失为一个好的选择。

下面我们来了解 Appium 的基本使用方法。

1. 本节目标

我们以 Android 平台的微信为例来演示 Appium 启动和操作 App 的方法,主要目的是了解利用 Appium 进行自动化测试的流程以及相关 API 的用法。

2. 准备工作

请确保 PC 已经安装好 Appium、Android 开发环境和 Python 版本的 Appium API,安装方法可以参考第 1 章。另外,Android 手机安装好微信 App。

3. 启动 APP

Appium 启动 App 的方式有两种:一种是用 Appium 内置的驱动器来打开 App,另一种是利用 Python 程序实现此操作。下面我们分别进行说明。

首先打开 Appium,启动界面如图 11-37 所示。

图 11-37 Appium 启动界面

直接点击 Start Server 按钮即可启动 Appium 的服务,相当于开启了一个 Appium 服务器。我们可以通过 Appium 内置的驱动或 Python 代码向 Appium 的服务器发送一系列操作指令,Appium 就会根据不同的指令对移动设备进行驱动,完成不同的动作。启动后运行界面如图 11-38 所示。

图 11-38 Server 运行界面

Appium 运行之后正在监听 4723 端口。我们可以向此端口对应的服务接口发送操作指令,此页面就会显示这个过程的操作日志。

将 Android 手机通过数据线和运行 Appium 的 PC 相连,同时打开 USB 调试功能,确保 PC 可以连接到手机。

可以输入 adb 命令来测试连接情况,如下所示:

1
adb devices -l

如果出现类似如下结果,这就说明 PC 已经正确连接手机。

1
2
List of devices attached
2da42ac0 device usb:336592896X product:leo model:MI_NOTE_Pro device:leo

model 是设备的名称,就是后文需要用到的 deviceName 变量。我使用的是小米 Note 顶配版,所以此处名称为 MI_NOTE_Pro。

如果提示找不到 adb 命令,请检查 Android 开发环境和环境变量是否配置成功。如果可以成功调用 adb 命令但不显示设备信息,请检查手机和 PC 的连接情况。

接下来用 Appium 内置的驱动器打开 App,点击 Appium 中的 Start New Session 按钮,如图 11-39 所示。

图 11-39 操作示例

这时会出现一个配置页面,如图 11-40 所示。

图 11-40 配置页面

需要配置启动 App 时的 Desired Capabilities 参数,它们分别是 platformName、deviceName、appPackage、appActivity。

  • platformName,平台名称,需要区分是 Android 还是 iOS,此处填写 Android。
  • deviceName,设备名称,是手机的具体类型。
  • appPackage,APP 程序包名。
  • appActivity,入口 Activity 名,这里通常需要以。开头。

在当前配置页面的左下角也有配置参数的相关说明,链接是 https://github.com/appium/appium/blob/master/docs/en/writing-running-appium/caps.md

我们在 Appium 中加入上面 4 个配置,如图 11-41 所示。

图 11-41 配置信息

点击保存按钮,保存下来,我们以后可以继续使用这个配置。

点击右下角的 Start Session 按钮,即可启动 Android 手机上的微信 App 并进入到启动页面。同时 PC 上会弹出一个调试窗口,从这个窗口我们可以预览当前手机页面,并可以查看页面的源码,如图 11-42 所示。

图 11-42 调试窗口

点击左栏中屏幕的某个元素,如选中登录按钮,它就会高亮显示。这时中间栏就显示了当前选中的按钮对应的源代码,右栏则显示了该元素的基本信息,如元素的 id、class、text 等,以及可以执行的操作,如 Tap、Send Keys、Clear,如图 11-43 所示。

图 11-43 操作选项

点击中间栏最上方的第三个录制按钮,Appium 会开始录制操作动作,这时我们在窗口中操作 App 的行为都会被记录下来,Recorder 处可以自动生成对应语言的代码。例如,我们点击录制按钮,然后选中 App 中的登录按钮,点击 Tap 操作,即模拟了按钮点击功能,这时手机和窗口的 App 都会跳转到登录页面,同时中间栏会显示此动作对应的代码,如图 11-44 所示。

图 11-44 录制动作

接下来选中左侧的手机号文本框,点击 Send Keys,对话框就会弹出。输入手机号,点击 Send Keys,即可完成文本的输入,如图 11-45 所示。

图 11-45 文本输入

我们可以在此页面点击不同的动作按钮,即可实现对 App 的控制,同时 Recorder 部分也可以生成对应的 Python 代码。

下面我们看看使用 Python 代码驱动 App 的方法。首先需要在代码中指定一个 Appium Server,而这个 Server 在刚才打开 Appium 的时候就已经开启了,是在 4723 端口上运行的,配置如下所示:

1
server = 'http://localhost:4723/wd/hub'

用字典来配置 Desired Capabilities 参数,代码如下所示:

1
2
3
4
5
6
desired_caps = {
'platformName': 'Android',
'deviceName': 'MI_NOTE_Pro',
'appPackage': 'com.tencent.mm',
'appActivity': '.ui.LauncherUI'
}

新建一个 Session,这类似点击 Appium 内置驱动的 Start Session 按钮相同的功能,代码实现如下所示:

1
2
3
4
from appium import webdriver
from selenium.webdriver.support.ui import WebDriverWait

driver = webdriver.Remote(server, desired_caps)

配置完成后运行,就可以启动微信 App 了。但是现在仅仅是可以启动 App,还没有做任何动作。

再用代码来模拟刚才演示的两个动作:一个是点击 “登录” 按钮,一个是输入手机号。

看看刚才 Appium 内置驱动器内的 Recorder 录制生成的 Python 代码,自动生成的代码非常累赘,例如点击 “登录” 按钮的代码如下所示:

1
2
el1 = driver.find_element_by_xpath("/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.view.View/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.RelativeLayout/android.widget.RelativeLayout/android.widget.Button[1]")
el1.click()

这段代码的 XPath 选择器路径太长,选择方式没有那么科学,获取元素时也没有设置等待,很可能会有超时异常。所以我们修改一下,将其修改为通过 ID 查找元素,设置延时等待,两次操作的代码改写如下所示:

1
2
3
4
5
wait = WebDriverWait(driver, 30)
login = wait.until(EC.presence_of_element_located((By.ID, 'com.tencent.mm:id/cjk')))
login.click()
phone = wait.until(EC.presence_of_element_located((By.ID, 'com.tencent.mm:id/h2')))
phone.set_text('18888888888')

综上所述,完整的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from appium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

server = 'http://localhost:4723/wd/hub'
desired_caps = {
'platformName': 'Android',
'deviceName': 'MI_NOTE_Pro',
'appPackage': 'com.tencent.mm',
'appActivity': '.ui.LauncherUI'
}
driver = webdriver.Remote(server, desired_caps)
wait = WebDriverWait(driver, 30)
login = wait.until(EC.presence_of_element_located((By.ID, 'com.tencent.mm:id/cjk')))
login.click()
phone = wait.until(EC.presence_of_element_located((By.ID, 'com.tencent.mm:id/h2')))
phone.set_text('18888888888')

一定要重新连接手机,再运行此代码,这时即可观察到手机上首先弹出了微信欢迎页面,然后模拟点击登录按钮、输入手机号,操作完成。这样我们就成功使用 Python 代码实现了 App 的操作。

4. API

接下来看看使用代码如何操作 App、总结相关 API 的用法。这里使用的 Python 库为 AppiumPythonClient,其 GitHub 地址为 https://github.com/appium/python-client,此库继承自 Selenium,使用方法与 Selenium 有很多共同之处。

初始化

需要配置 Desired Capabilities 参数,完整的配置说明可以参考 https://github.com/appium/appium/blob/master/docs/en/writing-running-appium/caps.md,一般来说我们我们配置几个基本参数即可:

1
2
3
4
5
6
7
8
9
10
from appium import webdriver

server = 'http://localhost:4723/wd/hub'
desired_caps = {
'platformName': 'Android',
'deviceName': 'MI_NOTE_Pro',
'appPackage': 'com.tencent.mm',
'appActivity': '.ui.LauncherUI'
}
driver = webdriver.Remote(server, desired_caps)

这里配置了启动微信 App 的 Desired Capabilities,这样 Appnium 就会自动查找手机上的包名和入口类,然后将其启动。包名和入口类的名称可以在安装包中的 AndroidManifest.xml 文件获取。

如果要打开的 App 没有事先在手机上安装,我们可以直接指定 App 参数为安装包所在路径,这样程序启动时就会自动向手机安装并启动 App,如下所示:

1
2
3
4
5
6
7
8
9
from appium import webdriver

server = 'http://localhost:4723/wd/hub'
desired_caps = {
'platformName': 'Android',
'deviceName': 'MI_NOTE_Pro',
'app': './weixin.apk'
}
driver = webdriver.Remote(server, desired_caps)

程序启动的时候就会寻找 PC 当前路径下的 APK 安装包,然后将其安装到手机中并启动。

查找元素

我们可以使用 Selenium 中通用的查找方法来实现元素的查找,如下所示:

1
el = driver.find_element_by_id('com.tencent.mm:id/cjk')

在 Selenium 中,其他查找元素的方法同样适用,在此不再赘述。

在 Android 平台上,我们还可以使用 UIAutomator 来进行元素选择,如下所示:

1
2
el = self.driver.find_element_by_android_uiautomator('new UiSelector().description("Animation")')
els = self.driver.find_elements_by_android_uiautomator('new UiSelector().clickable(true)')

在 iOS 平台上,我们可以使用 UIAutomation 来进行元素选择,如下所示:

1
2
el = self.driver.find_element_by_ios_uiautomation('.elements()[0]')
els = self.driver.find_elements_by_ios_uiautomation('.elements()')

还可以使用 iOS Predicates 来进行元素选择,如下所示:

1
2
el = self.driver.find_element_by_ios_predicate('wdName == "Buttons"')
els = self.driver.find_elements_by_ios_predicate('wdValue == "SearchBar" AND isWDDivisible == 1')

也可以使用 iOS Class Chain 来进行选择,如下所示:

1
2
el = self.driver.find_element_by_ios_class_chain('XCUIElementTypeWindow/XCUIElementTypeButton[3]')
els = self.driver.find_elements_by_ios_class_chain('XCUIElementTypeWindow/XCUIElementTypeButton')

但是此种方法只适用于 XCUITest 驱动,具体可以参考:https://github.com/appium/appium-xcuitest-
driver。

点击

点击可以使用 tap() 方法,该方法可以模拟手指点击(最多五个手指),可设置按时长短(毫秒),代码如下所示:

1
tap(self, positions, duration=None)

参数:

  • positions,点击的位置组成的列表。
  • duration,点击持续时间。

实例如下:

1
driver.tap([(100, 20), (100, 60), (100, 100)], 500)

这样就可以模拟点击屏幕的某几个点。

另外对于某个元素如按钮来说,我们可以直接调用 cilck() 方法实现模拟点击,实例如下所示:

1
2
button = find_element_by_id('com.tencent.mm:id/btn')
button.click()

这样获取元素之后,然后调用 click() 方法即可实现该元素的模拟点击。

屏幕拖动

可以使用 scroll() 方法模拟屏幕滚动,用法如下所示:

1
scroll(self, origin_el, destination_el)

可以实现从元素 origin_el 滚动至元素 destination_el。

参数:

  • original_el,被操作的元素
  • destination_el,目标元素

实例如下:

1
driver.scroll(el1,el2)

我们还可以使用 swipe() 模拟从 A 点滑动到 B 点,用法如下:

1
swipe(self, start_x, start_y, end_x, end_y, duration=None)

参数:

  • start_x,开始位置的横坐标
  • start_y,开始位置的纵坐标
  • end_x,终止位置的横坐标
  • end_y,终止位置的纵坐标
  • duration,持续时间,毫秒

实例如下:

1
driver.swipe(100, 100, 100, 400, 5000)

这样可以实现在 5s 由 (100, 100) 滑动到 (100, 400)。

另外可以使用 flick() 方法模拟从 A 点快速滑动到 B 点,用法如下:

1
flick(self, start_x, start_y, end_x, end_y)

参数:

  • start_x,开始位置的横坐标
  • start_y,开始位置的纵坐标
  • end_x,终止位置的横坐标
  • end_y,终止位置的纵坐标

实例如下:

1
driver.flick(100, 100, 100, 400)

拖拽

可以使用 drag_and_drop() 实现某个元素拖动到另一个目标元素上。

用法如下:

1
drag_and_drop(self, origin_el, destination_el)

可以实现元素 origin_el 拖拽至元素 destination_el。

参数:

  • original_el,被拖拽的元素
  • destination_el,目标元素

实例如下所示:

1
driver.drag_and_drop(el1, el2)

文本输入

可以使用 set_text() 方法实现文本输入,如下所示:

1
2
el = find_element_by_id('com.tencent.mm:id/cjk')
el.set_text('Hello')

我们选中一个文本框元素之后,然后调用 set_text() 方法即可实现文本输入。

动作链

与 Selenium 中的 ActionChains 类似,Appium 中的 TouchAction 可支持的方法有 tap()、press()、long_press()、release()、move_to()、wait()、cancel() 等,实例如下所示:

1
2
3
el = self.driver.find_element_by_accessibility_id('Animation')
action = TouchAction(self.driver)
action.tap(el).perform()

首先选中一个元素,然后利用 TouchAction 实现点击操作。

如果想要实现拖动操作,可以用如下方式:

1
2
3
4
5
els = self.driver.find_elements_by_class_name('listView')
a1 = TouchAction()
a1.press(els[0]).move_to(x=10, y=0).move_to(x=10, y=-75).move_to(x=10, y=-600).release()
a2 = TouchAction()
a2.press(els[1]).move_to(x=10, y=10).move_to(x=10, y=-300).move_to(x=10, y=-600).release()

利用以上 API,我们就可以完成绝大部分操作。更多的 API 操作可以参考 https://testerhome.com/topics/3711

5. 结语

本节中,我们主要了解了 Appium 的操作 App 的基本用法,以及常用 API 的用法。在下一节我们会用一个实例来演示 Appium 的使用方法。

Python

在开始了解 X-Forward-For 之前,我们先来假设一个场景。你是一名爬虫工程师,现在要爬取目标网站 xxx.com 上面的内容。在编码的时候,你发现单位时间内请求频率过高时会被限制,猜测应该是目标网站针对 IP 地址做了限制。现在你有两种选择:

  • 单机,用 IP 代理解决频率高被限制的问题。
  • 多机,用分布式爬虫解决单机 IP 被限制的问题。

由于目标网站只需要爬取一次,单机+IP 代理这种组合的成本更低,所以你选择了它。从 IP 代理服务商 xx 处购买了代理服务后,你进行了新一轮的测试,代码片段 Forwarded-Test 为测试代码。

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

# 请求地址
targetUrl = "http://111.231.93.117/"

# 代理服务器
proxyHost = "220.185.128.170"
proxyPort = "9999"

proxyMeta = "http://%(host)s:%(port)s" % {

"host": proxyHost,
"port": proxyPort,
}

proxies = {

"http": proxyMeta,
}
# 设定一个 Referer
header = {
"Referer": "http://www.sfhfpc.com",
}
resp = requests.get(targetUrl, proxies=proxies, headers=header)
print(resp.status_code)
print(resp.text)

代码片段 Forwarded-Test 代码运行后,你发现你仍然被限制! 顿时感到头大,于是在各大搜索引擎寻找相关资料,例如:

ip 代理无效 识别 ip 代理 ip 代理被发现

你发现很多文章中都提到一个东西 X-Forward-For,大家都说它能够看破 IP 代理。 那么问题来了:

  • X-Forward-For 到底是什么呢?
  • 为什么 X-Forward-For 能够发现我们使用了 IP 代理
  • 它怎么能找到原始 IP 呢?
  • 有什么方法可以骗过 X-Forward-For 呢?

带着这些问题,我们就来研究一下 X-Forward-For。

X-Forward-For 是什么

X-Forward-For 跟 Referer 和 User-Agent 一样,都是 HTTP 中的头域。HTTP/1.1 的 RFC 文档编号为 2616,在 2616 中并未提及 X-Forward-For,也就是说 HTTP/1.1 出现的时候 X-Forward-For 还没出生。真正提出 X-Forward-For 的是2014 年的 RFC7239(详见 https://www.rfc-editor.org/rfc/rfc7239.txt),这时候 X-Forward-For 作为HTTP 扩展出现。 RFC: 全称 Request For Comments,是一系列以编号排定的文件。它收集了互联网相关的协议信息,你可以抽象地将 RFC2616 理解为 HTTP/1.1 的协议规范。Websocket 协议规范的详细解读可参考《Python3 反爬虫原理与绕过实战》一书。 关于 X-Forward-For 的所有正确描述都写在了 RFC7239 中,所有符合规范的 HTTP 也会遵守 RFC7239。当然,你也可以选择不遵守不遵守: 实际上,RFC 只是一种规范、约定,作为大家统一行径的参考,并未强制实现。很多反爬虫手段就是另辟蹊径,采用了与 RFC 约定不同的策略,具体反爬虫思路和案例可参考《Python3 反爬虫原理与绕过实战》一书。 RFC7239 很长,我们不必逐一阅读。实际上跟我们相关的只有几个部分,例如:

1
2
1.Abstract
7.5. Example Usage

Abstract 是本文章的摘要,它描述了 RFC7239 的作用:

This document defines an HTTP extension header field that allows proxy components to disclose information lost in the proxying process, for example, the originating IP address of a request or IP address of the proxy on the user-agent-facing interface. In a path of proxying components, this makes it possible to arrange it so that each subsequent component will have access to, for example, all IP addresses used in the chain of proxied HTTP requests. This document also specifies guidelines for a proxy administrator to anonymize the origin of a request.

大体意思为本文的定义(扩展)了一个 HTTP 头域,这个字段允许代理组件披露原始 IP 地址。 从这里我们了解到 X-Forward-For 的正向用途是便于服务端识别原始 IP,并根据原始 IP 作出动态处理。例如服务端按照 IP 地址进行负载均衡时,如果能够看破 IP 代理,取得原始 IP 地址,那么就能够作出有效的负载。否则有可能造成资源分配不均,导致假负载均衡的情况出现。 Example Usage 给出了 X-Forward-For 的使用示例:

A request from a client with IP address 192.0.2.43 passes through a proxy with IP address 198.51.100.17, then through another proxy with IP address 203.0.113.60 before reaching an origin server. This could, for example, be an office client behind a corporate malware filter talking to a origin server through a reverse proxy. o The HTTP request between the client and the first proxy has no “Forwarded” header field. o The HTTP request between the first and second proxy has a “Forwarded: for=192.0.2.43” header field. o The HTTP request between the second proxy and the origin server has a “Forwarded: for=192.0.2.43, for=198.51.100.17;by=203.0.113.60;proto=http;host=example.com” header field.

假设原始 IP 为192.0.2.43,它的请求使用了地址为 198.51.100.17 的代理,在到达目标服务器 203.0.113.60 之前还使用了另外一个代理(文章假设另外一个代理为 222.111.222.111)。 这种情况下

  • 客户端和第一个代理之间的 HTTP 请求中没有 Forwarded 头域。
  • 第一个代理和第二个代理之间的 HTTP 请求中有 Forwarded 头域,头域及值为 Forwarded: for=192.0.2.43 。
  • 第二个代理和服务器之间的 HTTP 请求中有 Forwarded 头域,头域及值为 Forwarded: for=192.0.2.43, for=198.51.100.17;by=203.0.113.60;proto=http;host=example.com”

图 forwarded-client-server 描述了上述情景。 图 forwarded-client-server 由于客户端到代理 1 的请求没有使用代理,所以值为空或短横线。到代理 2 时,中间经过了代理 1,所以值为原始 IP。到服务端时,中间经过了代理 1 和代理2 ,所以值为原始 IP 和代理 1 IP。 上面就是关于 RFC7239 中部分内容的解读。看到这里,想必你已有丝丝头绪,接下来我们再捋一捋。

IP 代理实验

首先我在自己的测试服务器上安装并启动了 Nginx,它的默认日志格式如下:

1
2
3
4
log_format  main  
'$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

即 access.log 文件中会记录客户端 IP 地址、客户端时间、请求方式、响应状态码、响应正文大小、Referer、User-Agent 和代理清单。

提示:Nginx 中 $http_x_forwarded_for 对应的值这里称为代理清单,它与 RFC7239 中的 Forwarded 含义相同。

当我使用计算机终端浏览器访问测试服务器地址时,对应的日志记录如下:

1
180.137.156.168 - - [24/Nov/2019:12:41:19 +0800] "GET / HTTP/1.1" 200 612 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Safari/605.1.15" "-"

服务器记录到的信息含义如下:

  • 客户端 IP 为 180.137.156.168
  • 客户端时间为 [24/Nov/2019:12:41:19 +0800]
  • 请求方式为 GET / HTTP/1.1
  • 响应状态码为 200
  • 响应正文大小为 612
  • Referer 为短横线,即为空
  • User-Agent 显示浏览器品牌为 Safari
  • 代理清单为短横线,即为空。

由于本次并未使用 IP 代理,那么代理清单自然就是短横线。接着我们用 Python 代码测试一下,代码片段 Python-Request 为测试代码。

1
2
3
import requests
resp = requests.get("http://111.231.93.117/")
print(resp.status_code)

代码片段 Python-Request 代码运行结果为 200,即目标服务器正确响应了本次请求。对应的日志记录如下:

1
180.137.156.168 - - [24/Nov/2019:12:49:41 +0800] "GET / HTTP/1.1" 200 612 "-" "python-requests/2.21.0" "-"

这次也没有使用 IP 代理,所以代理清单依旧是短横线。现在用代理 IP 测试一下,代码片段 Forwarded-Test 中使用了 IP 代理,我们就用它进行测试即可。这里的代理服务器 IP 地址为 220.185.128.170,根据之前对 RFC7239 的了解,猜测本次请求对应的 Forwarded 记录的会是原始 IP,而客户端 IP 则是代理服务器的 IP。 代码运行后,服务器记录到对应的日志信息如下:

1
220.185.128.170 - - [24/Nov/2019:12:52:58 +0800] "GET / HTTP/1.1" 200 612 "http://www.sfhfpc.com" "python-requests/2.21.0" "180.137.156.168"

果然,记录中客户端 IP 对应的是 220.185.128.170,即代理服务器的 IP。Forwarded 中记录的 180.137.156.168 是 Python 程序所在的计算机 IP 地址,即原始 IP。 这与 RFC7239 的描述完全相符,服务端可以通过 Forwarded 找到原始 IP,甚至是使用过的代理服务器 IP。

调皮的 IP 代理商

刚才我们用的是普通 IP 代理,由于它很容易被识别,达不到隐匿的目的,所以 IP 代理商又推出了高匿代理高匿代理: 相对于普通 IP 代理而言,使用高匿代理后,原始 IP 会被隐藏得更好,服务端更难发现。 这里我使用了 芝麻代理 服务商提供的免费高匿 IP,注册后就可以领取免费 IP,简直就是开箱即用。 将代码片段 Forwarded-Test 中用于设置代理服务器 IP 和端口号的字段值改为高匿 IP 及对应的端口号即可,例如:

1
2
3
# 代理服务器
proxyHost = "58.218.92.132" # "220.185.128.170"
proxyPort = "2390" # "9999"

保存更改后运行代码,对应的日志记录如下:

1
125.82.188.4 - - [24/Nov/2019:13:05:07 +0800] "GET / HTTP/1.1" 200 612 "http://www.sfhfpc.com" "python-requests/2.21.0" "-"

原始 IP 为 125.82.188.4,代理清单为短横线。细心的你可能会有疑问,为什么填写的代理 IP 是 58.218.92.132,而日志中的却不是呢? 这是代理服务商做了多一层的转移,58.218.92.132 是给用户的入口,代理商的服务端会将入口为 58.218.92.132 的请求转给地址为 125.82.188.4。其中过程我们不用深究,高匿代理和普通代理的原理会再开一篇文章进行讨论。 日志记录说明高匿 IP 能够帮助我们实现隐匿的目的。说到这里不得不提一下,芝麻代理高匿 IP 的质量真的好,听说他们的 IP 还支持高并发调用,有需求的朋友不妨去试试。

机智的你和想当然的开发者

难道普通代理就一定会被 X-Forward-For 发现吗? 办法总是会有的,翻一下 http://www.sfhfpc.com 或者公众号韦世东学算法和反爬虫说不定灵感就来了!在解读 RFC7239 - Example Usage 时,我们了解到 X-Forward-For 会记录原始 IP,在使用多层 IP 代理的情况下记录的是上层 IP。利用这个特点,是不是可以伪造一下呢? 既然 X-Forward-For 和 Referer 一样是头域,那么就说明它可以被人为改变。我们只需要在请求时加上 X-Forward-For 请求头和对应的值即可。代码片段 Python-Request-CustomHeader 实现了这样的需求。

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

# 请求地址
targetUrl = "http://111.231.93.117/"

# 代理服务器
proxyHost = "220.185.128.170"
proxyPort = "9999"

proxyMeta = "http://%(host)s:%(port)s" % {

"host": proxyHost,
"port": proxyPort,
}

proxies = {
"http": proxyMeta,
}
header = {
"Referer": "http://www.sfhfpc.com",
"X-Forwarded-For": "_",
}
resp = requests.get(targetUrl, proxies=proxies, headers=header)
print(resp.status_code)
print(resp.text)

代码片段 Python-Request-CustomHeader 代码运行后,控制台结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
200
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

响应状态码是 200,并且返回了 Welcome to nginx 等字样,这说明请求成功。对应的日志记录为:

1
220.185.128.170 - - [24/Nov/2019:14:13:24 +0800] "GET / HTTP/1.1" 200 612 "http://www.sfhfpc.com" "python-requests/2.21.0" "_, 180.137.156.168"

记录显示,原始 IP 为 220.185.128.170、代理清单为 “_, 180.137.156.168”。实际上原始 IP 是 180.137.156.168,而代理服务器的 IP 是 220.185.128.170。代理清单中多出来的短横线是我们在代码中加上的,这里居然也显示了。这说明我们只需要在请求时附带上 X-Forward-For 头域就可以达到伪造的目的。 如果我想让服务端认为原始 IP 为 112.113.115.116,那么只需要将代码片段 Python-Request-CustomHeader 中 header 对象中 X-Forwarded-For 键对应的值设置为 112.113.115.116 即可。 保存后运行代码,对应的日志记录如下:

1
220.185.128.170 - - [24/Nov/2019:14:28:08 +0800] "GET / HTTP/1.1" 200 612 "http://www.sfhfpc.com" "python-requests/2.21.0" "112.113.115.116, 180.137.156.168"

根据 RFC7239 - Example Usage,开发者会认为代理清单中的第一组 IP 地址是原始 IP,殊不知这是我们特意为他准备的。

小结

X-Forward-For 是 HTTP 协议扩展的一个头域,它可以识别出经过多层代理后的原始 IP。捣蛋的人向来不喜欢遵守约定和规范,来了个鱼目混珠。更多关于 RFC 协议解读和通过违反约定实现的反爬虫措施可翻阅《Python3 反爬虫原理与绕过实战》一书。 提示:点击链接「免费领 IP」可前往芝麻代理领取免费 IP。 版权声明 作者:韦世东 链接:http://www.sfhfpc.com 来源:算法和反爬虫 著作权归作者所有,非商业转载请注明出处,禁止商业转载。

Python

11.3 mitmdump 爬取 “得到” App 电子书信息

“得到” App 是罗辑思维出品的一款碎片时间学习的 App,其官方网站为 https://www.igetget.com,App 内有很多学习资源。不过 “得到” App 没有对应的网页版,所以信息必须要通过 App 才可以获取。这次我们通过抓取其 App 来练习 mitmdump 的用法。

1. 爬取目标

我们的爬取目标是 App 内电子书版块的电子书信息,并将信息保存到 MongoDB,如图 11-30 所示。

我们要把图书的名称、简介、封面、价格爬取下来,不过这次爬取的侧重点还是了解 mitmdump 工具的用法,所以暂不涉及自动化爬取,App 的操作还是手动进行。mitmdump 负责捕捉响应并将数据提取保存。

2. 准备工作

请确保已经正确安装好了 mitmproxy 和 mitmdump,手机和 PC 处于同一个局域网下,同时配置好了 mitmproxy 的 CA 证书,安装好 MongoDB 并运行其服务,安装 PyMongo 库,具体的配置可以参考第 1 章的说明。

3. 抓取分析

首先探寻一下当前页面的 URL 和返回内容,我们编写一个脚本如下所示:

1
2
3
def response(flow):
print(flow.request.url)
print(flow.response.text)

这里只输出了请求的 URL 和响应的 Body 内容,也就是请求链接和响应内容这两个最关键的部分。脚本保存名称为 script.py。

接下来运行 mitmdump,命令如下所示:

1
mitmdump -s script.py

打开 “得到” App 的电子书页面,便可以看到 PC 端控制台有相应输出。接着滑动页面加载更多电子书,控制台新出现的输出内容就是 App 发出的新的加载请求,包含了下一页的电子书内容。控制台输出结果示例如图 11-31 所示。

图 11-31 控制台输出

可以看到 URL 为 https://dedao.igetget.com/v3/discover/bookList 的接口,其后面还加了一个 sign 参数。通过 URL 的名称,可以确定这就是获取电子书列表的接口。在 URL 的下方输出的是响应内容,是一个 JSON 格式的字符串,我们将它格式化,如图 11-32 所示。

图 11-32 格式化结果

格式化后的内容包含一个 c 字段、一个 list 字段,list 的每个元素都包含价格、标题、描述等内容。第一个返回结果是电子书《情人》,而此时 App 的内容也是这本电子书,描述的内容和价格也是完全匹配的,App 页面如图 11-33 所示。

图 11-33 APP 页面

这就说明当前接口就是获取电子书信息的接口,我们只需要从这个接口来获取内容就好了。然后解析返回结果,将结果保存到数据库。

4. 数据抓取

接下来我们需要对接口做过滤限制,抓取如上分析的接口,再提取结果中的对应字段。

这里,我们修改脚本如下所示:

1
2
3
4
5
6
7
8
9
10
11
import json
from mitmproxy import ctx

def response(flow):
url = 'https://dedao.igetget.com/v3/discover/bookList'
if flow.request.url.startswith(url):
text = flow.response.text
data = json.loads(text)
books = data.get('c').get('list')
for book in books:
ctx.log.info(str(book))

重新滑动电子书页面,在 PC 端控制台观察输出,如图 11-34 所示。

图 11-34 控制台输出

现在输出了图书的全部信息,一本图书信息对应一条 JSON 格式的数据。

5. 提取保存

接下来我们需要提取信息,再把信息保存到数据库中。方便起见,我们选择 MongoDB 数据库。

脚本还可以增加提取信息和保存信息的部分,修改代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import json
import pymongo
from mitmproxy import ctx

client = pymongo.MongoClient('localhost')
db = client['igetget']
collection = db['books']

def response(flow):
global collection
url = 'https://dedao.igetget.com/v3/discover/bookList'
if flow.request.url.startswith(url):
text = flow.response.text
data = json.loads(text)
books = data.get('c').get('list')
for book in books:
data = {'title': book.get('operating_title'),
'cover': book.get('cover'),
'summary': book.get('other_share_summary'),
'price': book.get('price')
}
ctx.log.info(str(data))
collection.insert(data)

重新滑动页面,控制台便会输出信息,如图 11-35 所示。

图 11-35 控制台输出

现在输出的每一条内容都是经过提取之后的内容,包含了电子书的标题、封面、描述、价格信息。

最开始我们声明了 MongoDB 的数据库连接,提取出信息之后调用该对象的 insert() 方法将数据插入到数据库即可。

滑动几页,发现所有图书信息都被保存到 MongoDB 中,如图 11-36 所示。

目前为止,我们利用一个非常简单的脚本把 “得到” App 的电子书信息保存下来。

6. 本节代码

本节的代码地址是:https://github.com/Python3WebSpider/IGetGet

7. 结语

本节主要讲解了 mitmdump 的用法及脚本的编写方法。通过本节的实例,我们可以学习到如何实时将 App 的数据抓取下来。

Python

11.2 mitmproxy 的使用

mitmproxy 是一个支持 HTTP 和 HTTPS 的抓包程序,有类似 Fiddler、Charles 的功能,只不过它是一个控制台的形式操作。 mitmproxy 还有两个关联组件。一个是 mitmdump,它是 mitmproxy 的命令行接口,利用它我们可以对接 Python 脚本,用 Python 实现监听后的处理。另一个是 mitmweb,它是一个 Web 程序,通过它我们可以清楚观察 mitmproxy 捕获的请求。 下面我们来了解它们的用法。

1. 准备工作

请确保已经正确安装好了 mitmproxy,并且手机和 PC 处于同一个局域网下,同时配置好了 mitmproxy 的 CA 证书,具体的配置可以参考第 1 章的说明。

2. mitmproxy 的功能

mitmproxy 有如下几项功能。

  • 拦截 HTTP 和 HTTPS 请求和响应
  • 保存 HTTP 会话并进行分析
  • 模拟客户端发起请求,模拟服务端返回响应
  • 利用反向代理将流量转发给指定的服务器
  • 支持 Mac 和 Linux 上的透明代理
  • 利用 Python 对 HTTP 请求和响应进行实时处理

3. 抓包原理

和 Charles 一样,mitmproxy 运行于自己的 PC 上,mitmproxy 会在 PC 的 8080 端口运行,然后开启一个代理服务,这个服务实际上是一个 HTTP/HTTPS 的代理。 手机和 PC 在同一个局域网内,设置代理为 mitmproxy 的代理地址,这样手机在访问互联网的时候流量数据包就会流经 mitmproxy,mitmproxy 再去转发这些数据包到真实的服务器,服务器返回数据包时再由 mitmproxy 转发回手机,这样 mitmproxy 就相当于起了中间人的作用,抓取到所有 Request 和 Response,另外这个过程还可以对接 mitmdump,抓取到的 Request 和 Response 的具体内容都可以直接用 Python 来处理,比如得到 Response 之后我们可以直接进行解析,然后存入数据库,这样就完成了数据的解析和存储过程。

4. 设置代理

首先,我们需要运行 mitmproxy,命令如下所示: 启动 mitmproxy 的命令如下:

1
mitmproxy

运行之后会在 8080 端口上运行一个代理服务,如图 11-12 所示: 图 11-12 mitmproxy 运行结果 右下角会出现当前正在监听的端口。 或者启动 mitmdump,它也会监听 8080 端口,命令如下所示:

1
mitmdump

运行结果如图 11-13 所示。 图 11-13 MitmDump 运行结果 将手机和 PC 连接在同一局域网下,设置代理为当前代理。首先看看 PC 的当前局域网 IP。 Windows 上的命令如下所示:

1
ipconfig

Linux 和 Mac 上的命令如下所示:

1
ifconfig

输出结果如图 11-14 所示: 图 11-14 查看局域网 IP 一般类似 10... 或 172.16.. 或 192.168.1. 这样的 IP 就是当前 PC 的局域网 IP,例如此图中 PC 的 IP 为 192.168.1.28,手机代理设置类似如图 11-15 所示。 图 11-15 代理设置 这样我们就配置好了 mitmproxy 的的代理。

5. mitmproxy 的使用

确保 mitmproxy 正常运行,并且手机和 PC 处于同一个局域网内,设置了 mitmproxy 的代理,具体的配置方法可以参考第 1 章。 运行 mitmproxy,命令如下所示:

1
mitmproxy

设置成功之后,我们只需要在手机浏览器上访问任意的网页或浏览任意的 App 即可。例如在手机上打开百度,mitmproxy 页面便会呈现出手机上的所有请求,如图 11-16 所示。 图 11-16 所有请求 这就相当于之前我们在浏览器开发者工具监听到的浏览器请求,在这里我们借助于 mitmproxy 完成。Charles 完全也可以做到。 这里是刚才手机打开百度页面时的所有请求列表,左下角显示的 2/38 代表一共发生了 38 个请求,当前箭头所指的是第二个请求。 每个请求开头都有一个 GET 或 POST,这是各个请求的请求方式。紧接的是请求的 URL。第二行开头的数字就是请求对应的响应状态码,后面是响应内容的类型,如 text/html 代表网页文档、image/gif 代表图片。再往后是响应体的大小和响应的时间。 当前呈现了所有请求和响应的概览,我们可以通过这个页面观察到所有的请求。 如果想查看某个请求的详情,我们可以敲击回车,进入请求的详情页面,如图 11-17 所示。 图 11-17 详情页面 可以看到 Headers 的详细信息,如 Host、Cookies、User-Agent 等。 最上方是一个 Request、Response、Detail 的列表,当前处在 Request 这个选项上。这时我们再点击 TAB 键,即可查看这个请求对应的响应详情,如图 11-18 所示。 图 11-18 响应详情 最上面是响应头的信息,下拉之后我们可以看到响应体的信息。针对当前请求,响应体就是网页的源代码。 这时再敲击 TAB 键,切换到最后一个选项卡 Detail,即可看到当前请求的详细信息,如服务器的 IP 和端口、HTTP 协议版本、客户端的 IP 和端口等,如图 11-19 所示。 图 11-19 详细信息 mitmproxy 还提供了命令行式的编辑功能,我们可以在此页面中重新编辑请求。敲击 e 键即可进入编辑功能,这时它会询问你要编辑哪部分内容,如 Cookies、Query、URL 等,每个选项的第一个字母会高亮显示。敲击要编辑内容名称的首字母即可进入该内容的编辑页面,如敲击 m 即可编辑请求的方式,敲击 q 即可修改 GET 请求参数 Query。 这时我们敲击 q,进入到编辑 Query 的页面。由于没有任何参数,我们可以敲击 a 来增加一行,然后就可以输入参数对应的 Key 和 Value,如图 11-20 所示。 图 11-20 编辑页面 这里我们输入 Key 为 wd,Value 为 NBA。 然后再敲击 esc 键和 q 键,返回之前的页面,再敲击 e 和 p 键修改 Path。和上面一样,敲击 a 增加 Path 的内容,这时我们将 Path 修改为 s,如图 11-21 所示。 图 11-21 编辑页面 再敲击 esc 和 q 键返回,这时我们可以看到最上面的请求链接变成了 https://www.baidu.com/s?wd=NBA,访问这个页面,可以看到百度搜索 NBA 关键词的搜索结果,如图 11-22 所示。 图 11-22 请求详情 敲击 a 保存修改,敲击 r 重新发起修改后的请求,即可看到上方请求方式前面多了一个回旋箭头,这说明重新执行了修改后的请求。这时我们再观察响应体内容,即可看到搜索 NBA 的页面结果的源代码,如图 11-23 所示。 图 11-23 响应结果 以上内容便是 mitmproxy 的简单用法。利用 mitmproxy,我们可以观察到手机上的所有请求,还可以对请求进行修改并重新发起。 Fiddler、Charles 也有这个功能,而且它们的图形界面操作更加方便。那么 mitmproxy 的优势何在? mitmproxy 的强大之处体现在它的另一个工具 mitmdump,有了它我们可以直接对接 Python 对请求进行处理。下面我们来看看 mitmdump 的用法。

6. MitmDump 的使用

mitmdump 是 mitmproxy 的命令行接口,同时还可以对接 Python 对请求进行处理,这是相比 Fiddler、Charles 等工具更加方便的地方。有了它我们可以不用手动截获和分析 HTTP 请求和响应,只需写好请求和响应的处理逻辑即可。它还可以实现数据的解析、存储等工作,这些过程都可以通过 Python 实现。

实例引入

我们可以使用命令启动 mitmproxy,并把截获的数据保存到文件中,命令如下所示:

1
mitmdump -w outfile

其中 outfile 的名称任意,截获的数据都会被保存到此文件中。 还可以指定一个脚本来处理截获的数据,使用 - s 参数即可:

1
mitmdump -s script.py

这里指定了当前处理脚本为 script.py,它需要放置在当前命令执行的目录下。 我们可以在脚本里写入如下的代码:

1
2
3
def request(flow):
flow.request.headers['User-Agent'] = 'MitmProxy'
print(flow.request.headers)

我们定义了一个 request() 方法,参数为 flow,它其实是一个 HTTPFlow 对象,通过 request 属性即可获取到当前请求对象。然后打印输出了请求的请求头,将请求头的 User-Agent 修改成了 MitmProxy。 运行之后我们在手机端访问 http://httpbin.org/get,就可以看到有如下情况发生。 手机端的页面显示如图 11-24 所示。 图 11-24 手机端页面 PC 端控制台输出如图 11-25 所示。 图 11-25 PC 端控制台 手机端返回结果的 Headers 实际上就是请求的 Headers,User-Agent 被修改成了 mitmproxy。PC 端控制台输出了修改后的 Headers 内容,其 User-Agent 的内容正是 mitmproxy。 所以,通过这三行代码我们就可以完成对请求的改写。print() 方法输出结果可以呈现在 PC 端控制台上,可以方便地进行调试。

日志输出

mitmdump 提供了专门的日志输出功能,可以设定不同级别以不同颜色输出结果。我们把脚本修改成如下内容:

1
2
3
4
5
6
7
from mitmproxy import ctx

def request(flow):
flow.request.headers['User-Agent'] = 'MitmProxy'
ctx.log.info(str(flow.request.headers))
ctx.log.warn(str(flow.request.headers))
ctx.log.error(str(flow.request.headers))

这里调用了 ctx 模块,它有一个 log 功能,调用不同的输出方法就可以输出不同颜色的结果,以方便我们做调试。例如,info() 方法输出的内容是白色的,warn() 方法输出的内容是黄色的,error() 方法输出的内容是红色的。运行结果如图 11-26 所示。 图 11-26 运行结果 不同的颜色对应不同级别的输出,我们可以将不同的结果合理划分级别输出,以更直观方便地查看调试信息。

Request

最开始我们实现了 request() 方法并且对 Headers 进行了修改。下面我们来看看 Request 还有哪些常用的功能。我们先用一个实例来感受一下。

1
2
3
4
5
6
7
8
9
10
11
12
from mitmproxy import ctx

def request(flow):
request = flow.request
info = ctx.log.info
info(request.url)
info(str(request.headers))
info(str(request.cookies))
info(request.host)
info(request.method)
info(str(request.port))
info(request.scheme)

我们修改脚本,然后在手机上打开百度,即可看到 PC 端控制台输出了一系列的请求,在这里我们找到第一个请求。控制台打印输出了 Request 的一些常见属性,如 URL、Headers、Cookies、Host、Method、Scheme 等。输出结果如图 11-27 所示。 图 11-27 输出结果 结果中分别输出了请求链接、请求头、请求 Cookies、请求 Host、请求方法、请求端口、请求协议这些内容。 同时我们还可以对任意属性进行修改,就像最初修改 Headers 一样,直接赋值即可。例如,这里将请求的 URL 修改一下,脚本修改如下所示:

1
2
3
def request(flow):
url = 'https://httpbin.org/get'
flow.request.url = url

手机端得到如下结果,如图 11-28 所示。 图 11-28 手机端页面 比较有意思的是,浏览器最上方还是呈现百度的 URL,但是页面已经变成了 httpbin.org 的页面了。另外,Cookies 明显还是百度的 Cookies。我们只是用简单的脚本就成功把请求修改为其他的站点。通过这种方式修改和伪造请求就变得轻而易举。 通过这个实例我们知道,有时候 URL 虽然是正确的,但是内容并非是正确的。我们需要进一步提高自己的安全防范意识。 Request 还有很多属性,在此不再一一列举。更多属性可以参考:http://docs.mitmproxy.org/en/latest/scripting/api.html。 只要我们了解了基本用法,会很容易地获取和修改 Reqeust 的任意内容,比如可以用修改 Cookies、添加代理等方式来规避反爬。

Response

对于爬虫来说,我们更加关心的其实是响应的内容,因为 Response Body 才是爬取的结果。对于响应来说,mitmdump 也提供了对应的处理接口,就是 response() 方法。下面我们用一个实例感受一下。

1
2
3
4
5
6
7
8
9
from mitmproxy import ctx

def response(flow):
response = flow.response
info = ctx.log.info
info(str(response.status_code))
info(str(response.headers))
info(str(response.cookies))
info(str(response.text))

将脚本修改为如上内容,然后手机访问:http://httpbin.org/get。 这里打印输出了响应的 status_code、headers、cookies、text 这几个属性,其中最主要的 text 属性就是网页的源代码。 PC 端控制台输出如图 11-29 所示。 图 11-29 PC 端控制台 控制台输出了响应的状态码、响应头、Cookies、响应体这几部分内容。 我们可以通过 response() 方法获取每个请求的响应内容。接下来再进行响应的信息提取和存储,我们就可以成功完成爬取了。

7. 结语

本节介绍了 mitmproxy 和 mitmdump 的用法,在下一节我们会利用它们来实现一个 App 的爬取实战。

Python

11.1 Charles 的使用

Charles 是一个网络抓包工具,我们可以用它来做 App 的抓包分析,得到 App 运行过程中发生的所有网络请求和响应内容,这就和 Web 端浏览器的开发者工具 Network 部分看到的结果一致。 相比 Fiddler 来说,Charles 的功能更强大,而且跨平台支持更好。所以我们选用 Charles 作为主要的移动端抓包工具,用于分析移动 App 的数据包,辅助完成 App 数据抓取工作。

1. 本节目标

本节我们以京东 App 为例,通过 Charles 抓取 App 运行过程中的网络数据包,然后查看具体的 Request 和 Response 内容,以此来了解 Charles 的用法。

2. 准备工作

请确保已经正确安装 Charles 并开启了代理服务,手机和 Charles 处于同一个局域网下,Charles 代理和 CharlesCA 证书设置好,另外需要开启 SSL 监听,具体的配置可以参考第 1 章的说明。

3. 原理

首先 Charles 运行在自己的 PC 上,Charles 运行的时候会在 PC 的 8888 端口开启一个代理服务,这个服务实际上是一个 HTTP/HTTPS 的代理。 确保手机和 PC 在同一个局域网内,我们可以使用手机模拟器通过虚拟网络连接,也可以使用手机真机和 PC 通过无线网络连接。 设置手机代理为 Charles 的代理地址,这样手机访问互联网的数据包就会流经 Charles,Charles 再转发这些数据包到真实的服务器,服务器返回的数据包再由 Charles 转发回手机,Charles 就起到中间人的作用,所有流量包都可以捕捉到,因此所有 HTTP 请求和响应都可以捕获到。同时 Charles 还有权力对请求和响应进行修改。

4. 抓包

初始状态下 Charles 的运行界面如图 11-1 所示: 图 11-1 Charles 运行界面 Charles 会一直监听 PC 和手机发生的网络数据包,捕获到的数据包就会显示在左侧,随着时间的推移,捕获的数据包越来越多,左侧列表的内容也会越来越多。 可以看到,图中左侧显示了 Charles 抓取到的请求站点,我们点击任意一个条目便可以查看对应请求的详细信息,其中包括 Request、Response 等内容。 接下来清空 Charles 的抓取结果,点击左侧的扫帚按钮即可清空当前捕获到的所有请求。然后点击第二个监听按钮,确保监听按钮是打开的,这表示 Charles 正在监听 App 的网络数据流,如图 11-2 所示。 图 11-2 监听过程 这时打开手机京东,注意一定要提前设置好 Charles 的代理并配置好 CA 证书,否则没有效果。 打开任意一个商品,如 iPhone,然后打开它的商品评论页面,如图 11-3 所示。 图 11-3 评论页面 不断上拉加载评论,可以看到 Charles 捕获到这个过程中京东 App 内发生的所有网络请求,如图 11-4 所示。 图 11-4 监听结果 左侧列表中会出现一个 api.m.jd.com 链接,而且它在不停闪动,很可能就是当前 App 发出的获取评论数据的请求被 Charles 捕获到了。我们点击将其展开,继续上拉刷新评论。随着上拉的进行,此处又会出现一个个网络请求记录,这时新出现的数据包请求确定就是获取评论的请求。 为了验证其正确性,我们点击查看其中一个条目的详情信息。切换到 Contents 选项卡,这时我们发现一些 JSON 数据,核对一下结果,结果有 commentData 字段,其内容和我们在 App 中看到的评论内容一致,如图 11-5 所示。 图 11-5 Json 数据结果 这时可以确定,此请求对应的接口就是获取商品评论的接口。这样我们就成功捕获到了在上拉刷新的过程中发生的请求和响应内容。

5. 分析

现在分析一下这个请求和响应的详细信息。首先可以回到 Overview 选项卡,上方显示了请求的接口 URL,接着是响应状态 Status Code、请求方式 Method 等,如图 11-6 所示。 图 11-6 监听结果 这个结果和原本在 Web 端用浏览器开发者工具内捕获到的结果形式是类似的。 接下来点击 Contents 选项卡,查看该请求和响应的详情信息。 上半部分显示的是 Request 的信息,下半部分显示的是 Response 的信息。比如针对 Reqeust,我们切换到 Headers 选项卡即可看到该 Request 的 Headers 信息,针对 Response,我们切换到 JSON TEXT 选项卡即可看到该 Response 的 Body 信息,并且该内容已经被格式化,如图 11-7 所示。 图 11-7 监听结果 由于这个请求是 POST 请求,所以我们还需要关心的就是 POST 的表单信息,切换到 Form 选项卡即可查看,如图 11-8 所示。 图 11-8 监听结果 这样我们就成功抓取 App 中的评论接口的请求和响应,并且可以查看 Response 返回的 JSON 数据。 至于其他 App,我们同样可以使用这样的方式来分析。如果我们可以直接分析得到请求的 URL 和参数的规律,直接用程序模拟即可批量抓取。

6. 重发

Charles 还有一个强大功能,它可以将捕获到的请求加以修改并发送修改后的请求。点击上方的修改按钮,左侧列表就多了一个以编辑图标为开头的链接,这就代表此链接对应的请求正在被我们修改,如图 11-9 所示。 图 11-9 编辑页面 我们可以将 Form 中的某个字段移除,比如这里将 partner 字段移除,然后点击 Remove。这时我们已经对原来请求携带的 Form Data 做了修改,然后点击下方的 Execute 按钮即可执行修改后的请求,如图 11-10 所示。 图 11-10 编辑页面 可以发现左侧列表再次出现了接口的请求结果,内容仍然不变,如图 11-11 所示。 图 11-11 重新请求后结果 删除 Form 表单中的 partner 字段并没有带来什么影响,所以这个字段是无关紧要的。 有了这个功能,我们就可以方便地使用 Charles 来做调试,可以通过修改参数、接口等来测试不同请求的响应状态,就可以知道哪些参数是必要的哪些是不必要的,以及参数分别有什么规律,最后得到一个最简单的接口和参数形式以供程序模拟调用使用。

7. 结语

以上内容便是通过 Charles 抓包分析 App 请求的过程。通过 Charles,我们成功抓取 App 中流经的网络数据包,捕获原始的数据,还可以修改原始请求和重新发起修改后的请求进行接口测试。 知道了请求和响应的具体信息,如果我们可以分析得到请求的 URL 和参数的规律,直接用程序模拟即可批量抓取,这当然最好不过了。 但是随着技术的发展,App 接口往往会带有密钥,我们并不能直接找到这些规律,那么怎么办呢?接下来,我们将了解利用 Charles 和 mitmdump 直接对接 Python 脚本实时处理抓取到的 Response 的过程。

Python

10.2 Cookies 池的搭建

很多时候,在爬取没有登录的情况下,我们也可以访问一部分页面或请求一些接口,因为毕竟网站本身需要做 SEO,不会对所有页面都设置登录限制。 但是,不登录直接爬取会有一些弊端,弊端主要有以下两点。

  • 设置了登录限制的页面无法爬取。如某论坛设置了登录才可查看资源,某博客设置了登录才可查看全文等,这些页面都需要登录账号才可以查看和爬取。
  • 一些页面和接口虽然可以直接请求,但是请求一旦频繁,访问就容易被限制或者 IP 直接被封,但是登录之后就不会出现这样的问题,因此登录之后被反爬的可能性更低。

下面我们就第二种情况做一个简单的实验。以微博为例,我们先找到一个 Ajax 接口,例如新浪财经官方微博的信息接口 https://m.weibo.cn/api/container/getIndex?uid=1638782947&luicode=20000174 &type=uid&value=1638782947&containerid=1005051638782947,如果用浏览器直接访问,返回的数据是 JSON 格式,如图 10-7 所示,其中包含了新浪财经官方微博的一些信息,直接解析 JSON 即可提取信息。 图 10-7 返回数据 但是,这个接口在没有登录的情况下会有请求频率检测。如果一段时间内访问太过频繁,比如打开这个链接,一直不断刷新,则会看到请求频率过高的提示,如图 10-8 所示。 图 10-8 提示页面 如果重新打开一个浏览器窗口,打开 https://passport.weibo.cn/signin/login?entry=mweibo&r\= https://m.weibo.cn/,登录微博账号之后重新打开此链接,则页面正常显示接口的结果,而未登录的页面仍然显示请求过于频繁,如图 10-9 所示。 图 10-9 对比页面 图中左侧是登录了账号之后请求接口的结果,右侧是未登录账号请求接口的结果,二者的接口链接是完全一样的。未登录状态无法正常访问,而登录状态可以正常显示。 因此,登录账号可以降低被封禁的概率。 我们可以尝试登录之后再做爬取,被封禁的几率会小很多,但是也不能完全排除被封禁的风险。如果一直用同一个账号频繁请求,那就有可能遇到请求过于频繁而封号的问题。 如果需要做大规模抓取,我们就需要拥有很多账号,每次请求随机选取一个账号,这样就降低了单个账号的访问频率,被封的概率又会大大降低。 那么如何维护多个账号的登录信息呢?这时就需要用到 Cookies 池了。接下来我们看看 Cookies 池的构建方法。

1. 本节目标

我们以新浪微博为例来实现一个 Cookies 池的搭建过程。Cookies 池中保存了许多新浪微博账号和登录后的 Cookies 信息,并且 Cookies 池还需要定时检测每个 Cookies 的有效性,如果某 Cookies 无效,那就删除该 Cookies 并模拟登录生成新的 Cookies。同时 Cookies 池还需要一个非常重要的接口,即获取随机 Cookies 的接口,Cookies 运行后,我们只需请求该接口,即可随机获得一个 Cookies 并用其爬取。 由此可见,Cookies 池需要有自动生成 Cookies、定时检测 Cookies、提供随机 Cookies 等几大核心功能。

2. 准备工作

搭建之前肯定需要一些微博的账号。需要安装好 Redis 数据库并使其正常运行。需要安装 Python 的 redis-py、requests、Selelnium 和 Flask 库。另外,还需要安装 Chrome 浏览器并配置好 ChromeDriver,其流程可以参考第一章的安装说明。

3. Cookies 池架构

Cookies 的架构和代理池类似,同样是 4 个核心模块,如图 10-10 所示。 图 10-10 Cookies 池架构 Cookies 池架构的基本模块分为 4 块:存储模块、生成模块、检测模块和接口模块。每个模块的功能如下。

  • 存储模块负责存储每个账号的用户名密码以及每个账号对应的 Cookies 信息,同时还需要提供一些方法来实现方便的存取操作。
  • 生成模块负责生成新的 Cookies。此模块会从存储模块逐个拿取账号的用户名和密码,然后模拟登录目标页面,判断登录成功,就将 Cookies 返回并交给存储模块存储。
  • 检测模块需要定时检测数据库中的 Cookies。在这里我们需要设置一个检测链接,不同的站点检测链接不同,检测模块会逐个拿取账号对应的 Cookies 去请求链接,如果返回的状态是有效的,那么此 Cookies 没有失效,否则 Cookies 失效并移除。接下来等待生成模块重新生成即可。
  • 接口模块需要用 API 来提供对外服务的接口。由于可用的 Cookies 可能有多个,我们可以随机返回 Cookies 的接口,这样保证每个 Cookies 都有可能被取到。Cookies 越多,每个 Cookies 被取到的概率就会越小,从而减少被封号的风险。

以上设计 Cookies 池的基本思路和前面讲的代理池有相似之处。接下来我们设计整体的架构,然后用代码实现该 Cookies 池。

4. Cookies 池的实现

首先分别了解各个模块的实现过程。

存储模块

其实,需要存储的内容无非就是账号信息和 Cookies 信息。账号由用户名和密码两部分组成,我们可以存成用户名和密码的映射。Cookies 可以存成 JSON 字符串,但是我们后面得需要根据账号来生成 Cookies。生成的时候我们需要知道哪些账号已经生成了 Cookies,哪些没有生成,所以需要同时保存该 Cookies 对应的用户名信息,其实也是用户名和 Cookies 的映射。这里就是两组映射,我们自然而然想到 Redis 的 Hash,于是就建立两个 Hash,结构分别如图 10-11 和图 10-12 所示。 图 10-11 用户名密码 Hash 结构 图 10-12 用户名 Cookies Hash 结构 Hash 的 Key 就是账号,Value 对应着密码或者 Cookies。另外需要注意,由于 Cookies 池需要做到可扩展,存储的账号和 Cookies 不一定单单只有本例中的微博,其他站点同样可以对接此 Cookies 池,所以这里 Hash 的名称可以做二级分类,例如存账号的 Hash 名称可以为 accounts:weibo,Cookies 的 Hash 名称可以为 cookies:weibo。如要扩展知乎的 Cookies 池,我们就可以使用 accounts:zhihu 和 cookies:zhihu,这样比较方便。 好,接下来我们就创建一个存储模块类,用以提供一些 Hash 的基本操作,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import random
import redis

class RedisClient(object):
def __init__(self, type, website, host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD):
"""
初始化 Redis 连接
:param host: 地址
:param port: 端口
:param password: 密码
"""
self.db = redis.StrictRedis(host=host, port=port, password=password, decode_responses=True)
self.type = type
self.website = website

def name(self):
"""
获取 Hash 的名称
:return: Hash 名称
"""return"{type}:{website}".format(type=self.type, website=self.website)

def set(self, username, value):
"""
设置键值对
:param username: 用户名
:param value: 密码或 Cookies
:return:
"""
return self.db.hset(self.name(), username, value)

def get(self, username):
"""
根据键名获取键值
:param username: 用户名
:return:
"""
return self.db.hget(self.name(), username)

def delete(self, username):
"""
根据键名删除键值对
:param username: 用户名
:return: 删除结果
"""
return self.db.hdel(self.name(), username)

def count(self):
"""
获取数目
:return: 数目
"""
return self.db.hlen(self.name())

def random(self):
"""
随机得到键值,用于随机 Cookies 获取
:return: 随机 Cookies
"""
return random.choice(self.db.hvals(self.name()))

def usernames(self):
"""
获取所有账户信息
:return: 所有用户名
"""
return self.db.hkeys(self.name())

def all(self):
"""
获取所有键值对
:return: 用户名和密码或 Cookies 的映射表
"""return self.db.hgetall(self.name())

这里我们新建了一个 RedisClient 类,初始化init() 方法有两个关键参数 type 和 website,分别代表类型和站点名称,它们就是用来拼接 Hash 名称的两个字段。如果这是存储账户的 Hash,那么此处的 type 为 accounts、website 为 weibo,如果是存储 Cookies 的 Hash,那么此处的 type 为 cookies、website 为 weibo。

接下来还有几个字段代表了 Redis 的连接信息,初始化时获得这些信息后初始化 StrictRedis 对象,建立 Redis 连接。

name() 方法拼接了 type 和 website,组成 Hash 的名称。set()、get()、delete() 方法分别代表设置、获取、删除 Hash 的某一个键值对,count() 获取 Hash 的长度。

比较重要的方法是 random(),它主要用于从 Hash 里随机选取一个 Cookies 并返回。每调用一次 random() 方法,就会获得随机的 Cookies,此方法与接口模块对接即可实现请求接口获取随机 Cookies。

生成模块

生成模块负责获取各个账号信息并模拟登录,随后生成 Cookies 并保存。我们首先获取两个 Hash 的信息,看看账户的 Hash 比 Cookies 的 Hash 多了哪些还没有生成 Cookies 的账号,然后将剩余的账号遍历,再去生成 Cookies 即可。

这里主要逻辑就是找出那些还没有对应 Cookies 的账号,然后再逐个获取 Cookies,代码如下:

​```python
for username in accounts_usernames:
if not username in cookies_usernames:
password = self.accounts_db.get(username)
print(‘ 正在生成 Cookies’, ‘ 账号 ‘, username, ‘ 密码 ‘, password)
result = self.new_cookies(username, password)

1
2

因为我们对接的是新浪微博,前面我们已经破解了新浪微博的四宫格验证码,在这里我们直接对接过来即可,不过现在需要加一个获取 Cookies 的方法,并针对不同的情况返回不同的结果,逻辑如下所示:

def get_cookies(self):
return self.browser.get_cookies()

def main(self):
self.open()
if self.password_error():
return {
‘status’: 2,
‘content’: ‘ 用户名或密码错误 ‘
}

# 如果不需要验证码直接登录成功
if self.login_successfully():
    cookies = self.get_cookies()
    return {
        'status': 1,
        'content': cookies
    }
# 获取验证码图片
image = self.get_image('captcha.png')
numbers = self.detect_image(image)
self.move(numbers)
if self.login_successfully():
    cookies = self.get_cookies()
    return {
        'status': 1,
        'content': cookies
    }
else:
    return {
        'status': 3,
        'content': ' 登录失败 '
    }
1
2

这里返回结果的类型是字典,并且附有状态码 status,在生成模块里我们可以根据不同的状态码做不同的处理。例如状态码为 1 的情况,表示成功获取 Cookies,我们只需要将 Cookies 保存到数据库即可。如状态码为 2 的情况,代表用户名或密码错误,那么我们就应该把当前数据库中存储的账号信息删除。如状态码为 3 的情况,则代表登录失败的一些错误,此时不能判断是否用户名或密码错误,也不能成功获取 Cookies,那么简单提示再进行下一个处理即可,类似代码实现如下所示:

result = self.new_cookies(username, password)

成功获取

if result.get(‘status’) == 1:
cookies = self.process_cookies(result.get(‘content’))
print(‘ 成功获取到 Cookies’, cookies)
if self.cookies_db.set(username, json.dumps(cookies)):
print(‘ 成功保存 Cookies’)

密码错误,移除账号

elif result.get(‘status’) == 2:
print(result.get(‘content’))
if self.accountsdb.delete(username):
print(‘ 成功删除账号 ‘)
else:
print(result.get(‘content’))

1
2
3
4
5
6

如果要扩展其他站点,只需要实现 new_cookies() 方法即可,然后按此处理规则返回对应的模拟登录结果,比如 1 代表获取成功,2 代表用户名或密码错误。 代码运行之后就会遍历一次尚未生成 Cookies 的账号,模拟登录生成新的 Cookies

#### 检测模块

我们现在可以用生成模块来生成 Cookies,但还是免不了 Cookies 失效的问题,例如时间太长导致 Cookies 失效,或者 Cookies 使用太频繁导致无法正常请求网页。如果遇到这样的 Cookies,我们肯定不能让它继续保存在数据库里。 所以我们还需要增加一个定时检测模块,它负责遍历池中的所有 Cookies,同时设置好对应的检测链接,我们用一个个 Cookies 去请求这个链接。如果请求成功,或者状态码合法,那么该 Cookies 有效;如果请求失败,或者无法获取正常的数据,比如直接跳回登录页面或者跳到验证页面,那么此 Cookies 无效,我们需要将该 Cookies 从数据库中移除。 此 Cookies 移除之后,刚才所说的生成模块就会检测到 CookiesHash 和账号的 Hash 相比少了此账号的 Cookies,生成模块就会认为这个账号还没生成 Cookies,那么就会用此账号重新登录,此账号的 Cookies 又被重新更新。 检测模块需要做的就是检测 Cookies 失效,然后将其从数据中移除。 为了实现通用可扩展性,我们首先定义一个检测器的父类,声明一些通用组件,实现如下所示:

class ValidTester(object):
def _init
(self, website=’default’):
self.website = website
self.cookies_db = RedisClient(‘cookies’, self.website)
self.accounts_db = RedisClient(‘accounts’, self.website)

def test(self, username, cookies):
    raise NotImplementedError

def run(self):
    cookies_groups = self.cookies_db.all()
    for username, cookies in cookies_groups.items():
        self.test(username, cookies)
1
2

在这里定义了一个父类叫作 ValidTester,在**init**() 方法里指定好站点的名称 website,另外建立两个存储模块连接对象 cookies_db 和 accounts_db,分别负责操作 Cookies 和账号的 Hash,run() 方法是入口,在这里是遍历了所有的 Cookies,然后调用 test() 方法进行测试,在这里 test() 方法是没有实现的,也就是说我们需要写一个子类来重写这个 test() 方法,每个子类负责各自不同网站的检测,如检测微博的就可以定义为 WeiboValidTester,实现其独有的 test() 方法来检测微博的 Cookies 是否合法,然后做相应的处理,所以在这里我们还需要再加一个子类来继承这个 ValidTester,重写其 test() 方法,实现如下:

import json
import requests
from requests.exceptions import ConnectionError

class WeiboValidTester(ValidTester):
def init(self, website=’weibo’):
ValidTester.init(self, website)

def test(self, username, cookies):
    print(' 正在测试 Cookies', ' 用户名 ', username)
    try:
        cookies = json.loads(cookies)
    except TypeError:
        print('Cookies 不合法 ', username)
        self.cookies_db.delete(username)
        print(' 删除 Cookies', username)
        return
    try:
        test_url = TEST_URL_MAP[self.website]
        response = requests.get(test_url, cookies=cookies, timeout=5, allow_redirects=False)
        if response.status_code == 200:
            print('Cookies 有效 ', username)
            print(' 部分测试结果 ', response.text[0:50])
        else:
            print(response.status_code, response.headers)
            print('Cookies 失效 ', username)
            self.cookies_db.delete(username)
            print(' 删除 Cookies', username)
    except ConnectionError as e:
        print(' 发生异常 ', e.args)
1
2

test() 方法首先将 Cookies 转化为字典,检测 Cookies 的格式,如果格式不正确,直接将其删除,如果格式没问题,那么就拿此 Cookies 请求被检测的 URL。test() 方法在这里检测微博,检测的 URL 可以是某个 Ajax 接口,为了实现可配置化,我们将测试 URL 也定义成字典,如下所示:

TESTURLMAP = {‘weibo’: ‘https://m.weibo.cn/'}

1
2
3
4
5
6

如果要扩展其他站点,我们可以统一在字典里添加。对微博来说,我们用 Cookies 去请求目标站点,同时禁止重定向和设置超时时间,得到响应之后检测其返回状态码。如果直接返回 200 状态码,则 Cookies 有效,否则可能遇到了 302 跳转等情况,一般会跳转到登录页面,则 Cookies 已失效。如果 Cookies 失效,我们将其从 CookiesHash 里移除即可。

#### 接口模块

生成模块和检测模块如果定时运行就可以完成 Cookies 实时检测和更新。但是 Cookies 最终还是需要给爬虫来用,同时一个 Cookies 池可供多个爬虫使用,所以我们还需要定义一个 Web 接口,爬虫访问此接口便可以取到随机的 Cookies。我们采用 Flask 来实现接口的搭建,代码如下所示:

import json
from flask import Flask, g
app = Flask(__name
)

生成模块的配置字典

GENERATOR_MAP = {‘weibo’: ‘WeiboCookiesGenerator’}
@app.route(‘/‘)
def index():
return ‘

Welcome to Cookie Pool System

def get_conn():
for website in GENERATOR_MAP:
if not hasattr(g, website):
setattr(g, website + ‘_cookies’, eval(‘RedisClient’ + ‘(“cookies”, “‘ + website + ‘“)’))
return g

@app.route(‘//random’)
def random(website):
“””
获取随机的 Cookie, 访问地址如 /weibo/random
:return: 随机 Cookie
“””
g = get_conn()
cookies = getattr(g, website + ‘_cookies’).random()
return cookies

1
2
3
4
5
6

我们同样需要实现通用的配置来对接不同的站点,所以接口链接的第一个字段定义为站点名称,第二个字段定义为获取的方法,例如,/weibo/random 是获取微博的随机 Cookies,/zhihu/random 是获取知乎的随机 Cookies。

#### 调度模块

最后,我们再加一个调度模块让这几个模块配合运行起来,主要的工作就是驱动几个模块定时运行,同时各个模块需要在不同进程上运行,实现如下所示:

import time
from multiprocessing import Process
from cookiespool.api import app
from cookiespool.config import
from cookiespool.generator import

from cookiespool.tester import *

class Scheduler(object):
@staticmethod
def valid_cookie(cycle=CYCLE):
while True:
print(‘Cookies 检测进程开始运行 ‘)
try:
for website, cls in TESTER_MAP.items():
tester = eval(cls + ‘(website=”‘ + website + ‘“)’)
tester.run()
print(‘Cookies 检测完成 ‘)
del tester
time.sleep(cycle)
except Exception as e:
print(e.args)

@staticmethod
def generate_cookie(cycle=CYCLE):
    while True:
        print('Cookies 生成进程开始运行 ')
        try:
            for website, cls in GENERATOR_MAP.items():
                generator = eval(cls + '(website="' + website + '")')
                generator.run()
                print('Cookies 生成完成 ')
                generator.close()
                time.sleep(cycle)
        except Exception as e:
            print(e.args)

@staticmethod
def api():
    print('API 接口开始运行 ')
    app.run(host=API_HOST, port=API_PORT)

def run(self):
    if API_PROCESS:
        api_process = Process(target=Scheduler.api)
        api_process.start()

    if GENERATOR_PROCESS:
        generate_process = Process(target=Scheduler.generate_cookie)
        generate_process.start()

    if VALID_PROCESS:
        valid_process = Process(target=Scheduler.valid_cookie)
        valid_process.start()
1
2

这里用到了两个重要的配置,即产生模块类和测试模块类的字典配置,如下所示:

产生模块类,如扩展其他站点,请在此配置

GENERATOR_MAP = {‘weibo’: ‘WeiboCookiesGenerator’}

测试模块类,如扩展其他站点,请在此配置

TESTER_MAP = {‘weibo’: ‘WeiboValidTester’}

1
2

这样的配置是为了方便动态扩展使用的,键名为站点名称,键值为类名。如需要配置其他站点可以在字典中添加,如扩展知乎站点的产生模块,则可以配置成:

GENERATOR_MAP = {
‘weibo’: ‘WeiboCookiesGenerator’,
‘zhihu’: ‘ZhihuCookiesGenerator’,
}
1
2

Scheduler 里将字典进行遍历,同时利用 eval() 动态新建各个类的对象,调用其入口 run() 方法运行各个模块。同时,各个模块的多进程使用了 multiprocessing 中的 Process 类,调用其 start() 方法即可启动各个进程。 另外,各个模块还设有模块开关,我们可以在配置文件中自由设置开关的开启和关闭,如下所示:

产生模块开关

GENERATOR_PROCESS = True

验证模块开关

VALID_PROCESS = False

接口模块开关

API_PROCESS = True

1
2

定义为 True 即可开启该模块,定义为 False 即关闭此模块。 至此,我们的 Cookies 就全部完成了。接下来我们将模块同时开启,启动调度器,控制台类似输出如下所示:

API 接口开始运行

  • Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
    Cookies 生成进程开始运行
    Cookies 检测进程开始运行
    正在生成 Cookies 账号 14747223314 密码 asdf1129
    正在测试 Cookies 用户名 14747219309
    Cookies 有效 14747219309
    正在测试 Cookies 用户名 14740626332
    Cookies 有效 14740626332
    正在测试 Cookies 用户名 14740691419
    Cookies 有效 14740691419
    正在测试 Cookies 用户名 14740618009
    Cookies 有效 14740618009
    正在测试 Cookies 用户名 14740636046
    Cookies 有效 14740636046
    正在测试 Cookies 用户名 14747222472
    Cookies 有效 14747222472
    Cookies 检测完成
    验证码位置 420 580 384 544
    成功匹配
    拖动顺序 [1, 4, 2, 3]
    成功获取到 Cookies {‘SUHB’: ‘08J77UIj4w5n_T’, ‘SCF’: ‘AimcUCUVvHjswSBmTswKh0g4kNj4K7_U9k57YzxbqFt4SFBhXq3Lx4YSNO9VuBV841BMHFIaH4ipnfqZnK7W6Qs.’, ‘SSOLoginState’: ‘1501439488’, ‘_T_WM’: ‘99b7d656220aeb9207b5db97743adc02’, ‘M_WEIBOCN_PARAMS’: ‘uicode%3D20000174’, ‘SUB’: ‘_2A250elZQDeRhGeBM6VAR8ifEzTuIHXVXhXoYrDV6PUJbkdBeLXTxkW17ZoYhhJ92N_RGCjmHpfv9TB8OJQ..’}
    成功保存 Cookies
    ```

以上所示是程序运行的控制台输出内容,我们从中可以看到各个模块都正常启动,测试模块逐个测试 Cookies,生成模块获取尚未生成 Cookies 的账号的 Cookies,各个模块并行运行,互不干扰。 我们可以访问接口获取随机的 Cookies,如图 10-13 所示。 图 10-13 接口页面 爬虫只需要请求该接口就可以实现随机 Cookies 的获取。

5. 本节代码

本节代码地址:https://github.com/Python3WebSpider/CookiesPool

6. 结语

以上内容便是 Cookies 池的用法,后文中我们会利用该 Cookies 池和之前所讲的代理池来进行新浪微博的大规模爬取。

Python

我们先以一个最简单的实例来了解模拟登录后页面的抓取过程,其原理在于模拟登录后 Cookies 的维护。

1. 本节目标

本节将讲解以 GitHub 为例来实现模拟登录的过程,同时爬取登录后才可以访问的页面信息,如好友动态、个人信息等内容。 我们应该都听说过 GitHub,如果在我们在 Github 上关注了某些人,在登录之后就会看到他们最近的动态信息,比如他们最近收藏了哪个 Repository,创建了哪个组织,推送了哪些代码。但是退出登录之后,我们就无法再看到这些信息。 如果希望爬取 GitHub 上所关注人的最近动态,我们就需要模拟登录 GitHub。

2. 环境准备

请确保已经安装好了 requests 和 lxml 库,如没有安装可以参考第 1 章的安装说明。

3. 分析登录过程

首先要分析登录的过程,需要探究后台的登录请求是怎样发送的,登录之后又有怎样的处理过程。 如果已经登录 GitHub,先退出登录,同时清除 Cookies。 打开 GitHub 的登录页面,链接为 https://github.com/login,输入 GitHub 的用户名和密码,打开开发者工具,将 Preserve Log 选项勾选上,这表示显示持续日志,如图 10-1 所示。 图 10-1 开发者工具设置 点击登录按钮,这时便会看到开发者工具下方显示了各个请求过程,如图 10-2 所示。 图 10-2 请求过程 点击第一个请求,进入其详情页面,如图 10-3 所示。 图 10-3 详情页面 可以看到请求的 URL 为 https://github.com/session,请求方式为 POST。再往下看,我们观察到它的 Form Data 和 Headers 这两部分内容,如图 10-4 所示。 图 10-4 详情页面 Headers 里面包含了 Cookies、Host、Origin、Referer、User-Agent 等信息。Form Data 包含了 5 个字段,commit 是固定的字符串 Sign in,utf8 是一个勾选字符,authenticity_token 较长,其初步判断是一个 Base64 加密的字符串,login 是登录的用户名,password 是登录的密码。 综上所述,我们现在无法直接构造的内容有 Cookies 和 authenticity_token。下面我们再来探寻一下这两部分内容如何获取。 在登录之前我们会访问到一个登录页面,此页面是通过 GET 形式访问的。输入用户名密码,点击登录按钮,浏览器发送这两部分信息,也就是说 Cookies 和 authenticity_token 一定是在访问登录页的时候设置的。 这时再退出登录,回到登录页,同时清空 Cookies,重新访问登录页,截获发生的请求,如图 10-5 所示。 图 10-5 截获请求 访问登录页面的请求如图所示,Response Headers 有一个 Set-Cookie 字段。这就是设置 Cookies 的过程。 另外,我们发现 Response Headers 没有和 authenticity_token 相关的信息,所以可能 authenticity_token 还隐藏在其他的地方或者是计算出来的。我们再从网页的源码探寻,搜索相关字段,发现源代码里面隐藏着此信息,它是一个隐藏式表单元素,如图 10-6 所示。 图 10-6 表单元素 现在我们已经获取到所有信息,接下来实现模拟登录。

4. 代码实战

首先我们定义一个 Login 类,初始化一些变量:

1
2
3
4
5
6
7
8
9
10
11
class Login(object):
def __init__(self):
self.headers = {
'Referer': 'https://github.com/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36',
'Host': 'github.com'
}
self.login_url = 'https://github.com/login'
self.post_url = 'https://github.com/session'
self.logined_url = 'https://github.com/settings/profile'
self.session = requests.Session()

这里最重要的一个变量就是 requests 库的 Session,它可以帮助我们维持一个会话,而且可以自动处理 Cookies,我们不用再去担心 Cookies 的问题。 接下来,访问登录页面要完成两件事:一是通过此页面获取初始的 Cookies,二是提取出 authenticity_token。 在这里我们实现一个 token() 方法,如下所示:

1
2
3
4
5
6
7
from lxml import etree

def token(self):
response = self.session.get(self.login_url, headers=self.headers)
selector = etree.HTML(response.text)
token = selector.xpath('//div/input[2]/@value')[0]
return token

我们用 Session 对象的 get() 方法访问 GitHub 的登录页面,然后用 XPath 解析出登录所需的 authenticity_token 信息并返回。 现在已经获取初始的 Cookies 和 authenticity_token,开始模拟登录,实现一个 login() 方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def login(self, email, password):
post_data = {
'commit': 'Sign in',
'utf8': '✓',
'authenticity_token': self.token(),
'login': email,
'password': password
}

response = self.session.post(self.post_url, data=post_data, headers=self.headers)
if response.status_code == 200:
self.dynamics(response.text)

response = self.session.get(self.logined_url, headers=self.headers)
if response.status_code == 200:
self.profile(response.text)

首先构造一个表单,复制各个字段,其中 email 和 password 是以变量的形式传递。然后再用 Session 对象的 post() 方法模拟登录即可。由于 requests 自动处理了重定向信息,我们登录成功后就可以直接跳转到首页,首页会显示所关注人的动态信息,得到响应之后我们用 dynamics() 方法来对其进行处理。接下来再用 Session 对象请求个人详情页,然后用 profile() 方法来处理个人详情页信息。 其中,dynamics() 方法和 profile() 方法的实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
def dynamics(self, html):
selector = etree.HTML(html)
dynamics = selector.xpath('//div[contains(@class, "news")]//div[contains(@class, "alert")]')
for item in dynamics:
dynamic = ' '.join(item.xpath('.//div[@class="title"]//text()')).strip()
print(dynamic)

def profile(self, html):
selector = etree.HTML(html)
name = selector.xpath('//input[@id="user_profile_name"]/@value')[0]
email = selector.xpath('//select[@id="user_profile_email"]/option[@value!=""]/text()')
print(name, email)

在这里,我们仍然使用 XPath 对信息进行提取。在 dynamics() 方法里,我们提取了所有的动态信息,然后将其遍历输出。在 prifile() 方法里,我们提取了个人的昵称和绑定的邮箱,然后将其输出。 这样,整个类的编写就完成了。

5. 运行

我们新建一个 Login 对象,然后运行程序,如下所示:

1
2
3
if __name__ == "__main__":
login = Login()
login.login(email='cqc@cuiqingcai.com', password='password')

在 login() 方法传入用户名和密码,实现模拟登录。 可以看到控制台有类似如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
GrahamCampbell  starred  nunomaduro/zero-framework
GrahamCampbell starred nunomaduro/laravel-zero
happyAnger6 created repository happyAnger6/nodejs_chatroom
viosey starred nitely/Spirit
lbgws2 starred Germey/TaobaoMM
EasyChris starred ageitgey/face_recognition
callmewhy starred macmade/GitHubUpdates
sindresorhus starred sholladay/squatter
SamyPesse starred graphcool/chromeless
wbotelhos starred tkadlec/grunt-perfbudget
wbotelhos created repository wbotelhos/eggy
leohxj starred MacGesture/MacGesture
GrahamCampbell starred GrahamCampbell/Analyzer
EasyChris starred golang/go
mitulgolakiya starred veltman/flubber
liaoyuming pushed to student at Germey/SecurityCourse
leohxj starred jasonslyvia/a-cartoon-intro-to-redux-cn
ruanyf starred ericchiang/pup
ruanyf starred bpesquet/thejsway
louwailou forked Germey/ScrapyTutorial to louwailou/ScrapyTutorial
Lving forked shadowsocksr-backup/shadowsocksr to Lving/shadowsocksr
qifuren1985 starred Germey/ADSLProxyPool
QWp6t starred laravel/framework
Germey ['1016903103@qq.com', 'cqc@cuiqingcai.com']

可以发现,我们成功获取到关注的人的动态信息和个人的昵称及绑定邮箱。模拟登录成功!

6. 本节代码

本节代码地址:https://github.com/Python3WebSpider/GithubLogin

7. 结语

我们利用 requests 的 Session 实现了模拟登录操作,其中最重要的还是分析思路,只要各个参数都成功获取,那么模拟登录是没有问题的。 登录成功,这就相当于建立了一个 Session 会话,Session 对象维护着 Cookies 的信息,直接请求就会得到模拟登录成功后的页面。

技术杂谈

最近工作遇到了一个问题。对我们公司的开发小组来说,整个小组的人员都在一个 Repository 下面协作,这个 Repository 里面的文件夹非常多,而我只负责其中的一个功能的开发,我开发的功能所在的文件夹是可以独立维护的,它不依赖于 Repository 里面的其他的任何一个文件夹。

现在我新招到了一位实习生,会跟我一同做这个功能。但很尴尬的是,原则上来说实习生是不能有整个 Repository 的权限的,因为其他的文件夹下可能有包含一些关键信息,那我又怎么把我的这一部分的代码共享给他呢?

有的小伙伴可能说可以用软连接,但是用软连接的话实际上是不行的,因为 git 在 commit 软连接的时候会把它当成文件对待的。

比如说我有一个文件夹啊,我创建了一个软连接到这个文件夹,创建的链接文件实际上是不能以文件夹的形式提交到 Git 仓库的。

那么怎么办呢?硬链接就好了。

我使用的是 Mac OS 系统,可选的方案有 hln、bindfs,但前者是不能链接文件夹的。

一个比较可行的方案就是使用 bindfs,安装方法如下:

1
brew install bindfs

然后使用如下命令即可:

1
bindfs source target

这样的话,比如我大库里面有个文件夹,名字叫做 foo,我就可以在我其他的目录下创建一个对该目录的挂载点 bar。

1
bindfs /var/project1/foo /var/project2/bar

这样我在 project1 下修改 foo 文件夹下的内容,project2 下的 bar 文件夹下的内容也会跟着修改了,我只需要把想要链接的文件夹都放在 project2 下,project2 作为一个独立的 Git 仓库,实习生只能看到我分离出来的内容,看不到大库 project1 下的内容。

这样如果实习生更新了 project2 的 bar 文件夹,提交到了 project2 对应的 Git 仓库,我从上面 pull 下代码,这样 project1 里面的 foo 文件夹也会跟着更新了,这样我再把新的改动提交到 project1 即可。

技术杂谈

做爬虫的同学肯定或多或少会为验证码苦恼过,在最初的时候,大部分验证码都是图形验证码。但是前几年「极验」验证码横空出世,行为验证码变得越来越流行,其中之一的形式便是滑块验证码。 滑块验证码是怎样的呢?如图所示,验证码是一张矩形图,图片左侧会出现一个滑块,右侧会出现一个缺口,下侧会出现一个滑轨。左侧的滑块会随着滑轨的拖动而移动,如果能将左侧滑块正好滑动到右侧缺口处,就算完成了验证。 image-20191107023051548 由于这种验证码交互形式比较友好,且安全性、美观度上也会更高,像这种类似的验证码也变得越来越流行。另外不仅仅是「极验」,其他很多验证码服务商也推出了类似的验证码服务,如「网易易盾」等,上图所示的就是「网易易盾」的滑动验证码。 没错,确实这种滑动验证码的出现让很多网站变得更安全。但是做爬虫的可就苦恼了,如果采用自动化的方法来绕过这种滑动验证码,关键部分在于以下两点:

  • 找出目标缺口的位置。
  • 模拟人的滑动轨迹将滑块滑动到缺口处。

那么问题来了,第一步怎么做呢? 我们怎么识别目标缺口到底在图片的哪个地方?大家可能想到的答案有:

  • 直接手工一把梭。
  • 利用图像处理算法检测缺口处特征。
  • 对接打码平台,获取缺口位置。

另外对于极验来说,之前还有一种方法来识别缺口,那就是对比原图和缺口图的不同之处,通过遍历像素点来找出缺口的位置,但这种方法就比较投机了。如果换家验证码服务商,不给我们原图,我们就无从比较计算了。 总之,我们的目标就是输入一张图,输出缺口的的位置。 上面的方法呢,要么费时费钱、要么准确率不高。那还有没有其他的解决方案呢? 当然有。 现在深度学习这么火,基于深度学习的图像识别技术已经发展得比较成熟了。那么我们能不能利用它来识别缺口位置呢? 答案是,没问题,我们只需要将这个问题归结成一个深度学习的「目标检测」问题就好了。 听到这里,现在可能有的同学已经望而却步了,深度学习?我一点基础都没有呀,咋办? 不用担心,本节介绍的内容全程没有一行代码,不需要任何深度学习基础,我们只需要动动手点一点就能搭建一个识别验证码缺口的深度学习的模型。 这么神奇?是的,那么本节我就带大家来实现一下吧。

目标检测

首先在开始之前简单说下目标检测。什么叫目标检测?顾名思义,就是把我们想找的东西找出来。比如给一张「狗」的图片,如图所示: image-20191107024841075 我们想知道这只狗在哪,它的舌头在哪,找到了就把它们框选出来,这就是目标检测。 经过目标检测算法处理之后,我们期望得到的图片是这样的: image-20191107025008947 可以看到这只狗和它的舌头就被框选出来了,这就完成了一个不错的目标检测。 现在比较流行的目标检测算法有 R-CNN、Fast R-CNN、Faster R-CNN、SSD、YOLO 等,感兴趣同学的可以了解一下,当然看不懂也没有什么影响。 另外再提一个地方,不懂深度学习的同学可以看看,懂的直接跳过下面一段。 我们既然要搭建一个模型来实现一个目标检测算法,那模型怎么知道我们究竟想识别个什么东西?就比如上图,模型咋知道我们想识别的是狗而不是草,是舌头而不是鼻子。这是因为,既然叫深度学习,那得有学习的东西。所以,搭建一个深度学习模型需要训练数据。啥也不告诉模型,模型从哪里去学习?所以,我们得预先有一些标注好位置的图片供模型去学习(训练),比如准备好多张狗的图片和狗的轮廓标注位置,模型在训练过程中会自动学习到图片和标注位置的关系。模型训练好了之后,我们给模型一个没有见过的类似的狗的图,模型也能找出来目标的位置了。 所以,迁移到验证码缺口识别这个任务上来,我们第一步就是给模型提供一些训练数据,训练数据就包括验证码的图片和缺口的位置标注轮廓信息。 好,既然如此,我们第一步就得准备一批验证码数据供标注和训练了。

准备训练数据

这里我用的是网易易盾的验证码,链接为:http://dun.163.com/trial/jigsaw。 我写爬虫爬下来了一些验证码的图,具体怎么爬的就不再赘述了,简单粗暴直接干就行了。 爬下来的验证码图类似这样子: image-20191107030722603 我们不需要滑轨的部分,只保留验证码本身的图片和上面的两个缺口就行了,下面是我准备的一些验证码图: image-20191107030825681 我爬了大约上千张吧,越多越好。当然对于今天的任务来说,其实几十上百张已经就够了。

标注缺口位置

下一步就是把缺口的位置标注出来了。想一想这一步又不太好办,我难道还得每张图片量一量吗?这费了劲了,那咋整啊? 很多同学可能到了这一步就望而却步了,更别提后面的搭建模型训练了。 但我们在文章开头说了,我们不需要写一行代码,点一点就能把模型搭建好。怎么做到的呢?我们可以借助于一些平台和工具。 在这里就要请出今天的主角—— ModelArts 了,这是我发现的华为云的一个深度学习平台,借助它我们可以完成数据标注、模型训练、模型部署三个步骤,最重要的是,我们不需要写代码,只需要点来点去就可以完成了。 让我们进入 ModelArts 来看看: image-20191107031802815 它已经内置了一些深度学习模型,包括图像分类、物体检测、预测分析等等,我们可以直接利用它们来快速搭建属于自己的模型。 在这里我们就切换到「自动学习」的选项卡,创建一个物体检测的项目。 image-20191107032040036 进入项目里面,可以看到最上面会显示三个步骤:

  • 数据标注
  • 模型训练
  • 部署上线

也就是说,经过这三步,我们就可以搭建和部署一个深度学习模型。 页面如图所示: image-20191107032248156 那我们先来第一步——数据标注,这里我把一些验证码的图上传到页面中,在这里我上传了 112 张图: image-20191107032407896 上传完毕之后我们可以点击每一张图片进行标注了,这个平台提供了非常方便的标注功能,只需要鼠标拖拽个轮廓就完成了,112 张图标注完也就几分钟,标注的时候就框选这么个轮廓就行了,如图所示: image-20191107032556453 在这里边界需要把整个缺口的图全框选出来,其中上边界和右边界和标注框相切即可,总之确保标注框正好把缺口图框选出来就行,平台会自动保存和记录标注的像素点位置。 标注完一个,它会提示要添加一个名字,我在这里添加的名字叫「边界」,可以随意指定。 等全部标注完毕,点击「保存并返回」按钮即可。

训练

好,标注完了我们就可以开始训练了。我们在这里不需要写任何的代码,因为平台已经给我们写好了,内置了目标检测的深度学习模型,我们只需要提供数据训练就行了,如图所示: image-20191107033005181 在这里,我们只需要设置一下「最大训练时长」就好了,这么点图片其实几分钟就能训练完了,「最大训练时长」随意填写即可,最小不小于 0.05,填写完了之后就可以点击「开始训练」按钮训练了。 等几分钟,就会训练完成了,可以看到类似如图的页面: image-20191107033211474 这里显示了模型的各个参数和指标。 是的,你没看错,我们没有写任何代码,只过了几分钟,模型就已经训练完,并且可以部署上线了。

部署测试

然后进行下一步,部署上线,直接点击左上角的部署按钮即可: image-20191107033411530 过一会儿, 部署成功之后便可以看到类似这样的界面: image-20191107033446107 在这里我们可以上传任意的验证码图片进行测试,比如我随意上传一张没有标注过的验证码图,然后它会给我们展示出预测结果,如图所示: image-20191107033907756 可以看到,它就把缺口的位置检测出来了,同时在右侧显示了具体的像素值和置信度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"detection_classes": [
"边界"
],
"detection_boxes": [
[
16.579784393310547,
331.89569091796875,
124.46369934082031,
435.0449523925781
]
],
"detection_scores": [
0.9999219179153442
]
}

是的,检测的结果还是比较准确的。有了这个结果,我们下一步模拟滑动到标注结果的左边界位置就好了!具体的模拟过程这里就不展开讲了。 另外平台同时还提供了模型部署后的 URL 接口和接口调用指南,也就是我们只需要向接口上传任意的验证码图片,就可以得到缺口的位置了!调用方式可以见:https://support.huaweicloud.com/engineers-modelarts/modelarts_23_0063.html。 嗯,就是这样,我们通过非常简单的操作,不需要任何代码,几分钟就搭建了一个深度学习模型,准确率也还不错。 当然这里我们只标注了 100 多张,标注得越多,标注得越精确,模型的准确率也会越高的。 以上便是利用 ModelArts 搭建滑动验证码缺口识别模型的方法,十分简洁高效。大家感兴趣可以了解下 ModelArts:https://www.huaweicloud.com/product/modelarts.html

技术杂谈

人工智能技术(以下称 AI)是人类优秀的发现和创造之一,它代表着至少几十年的未来。在传统的编程中,工程师将自己的想法和业务变成代码,计算机会根据代码设定的逻辑运行。与之不同的是,AI 使计算机有了「属于自己的思想」,它就像生物一样,能够「看」、「听」、「说」、「动」、「理解」、「分辨」和「思考」。 AI 在图像识别和文本处理方面的效果尤为突出,且已经应用到人类的生活中,例如人脸识别、对话、车牌识别、城市智慧大脑项目中的目标检测和目标分类等。 接下来,我们将了解图像分类的需求、完成任务的前提条件和任务实践。

图像分类以及目标检测的需求

AI 的能力和应用都非常广泛,这里我们主要讨论的是图像分类。 图像分类,其实是对图像中主要目标的识别和归类。例如在很多张随机图片中分辨出哪一张中有直升飞机、哪一张中有狗。或者给定一张图片,让计算机分辨图像中主要目标的类别。 目标检测,指的是检测目标在图片中的位置。例如智慧交通项目中,路面监控摄像头拍摄画面中车辆的位置。目标检测涉及两种技术:分类和定位。也就是说先判定图片中是否存在指定的目标,然后还需要确定目标在图片中的位置。 这样的技术将会应用在人脸识别打卡、视频监控警报、停车场、高速收费站和城市智慧交通等项目当中。

计算机识图的步骤

我们可以将计算机的看作是一个小朋友,它在拥有「分辨」的能力之前,必须经历「看」和「认识」这两个步骤,在看过很多图片后,它就会形成自己的「认知」,也就是获得了「分辨」能力。 简单来说,AI 工程师必须准备很多张不同的图片,并且将一大部分图片中的目标标注出来,然后让计算机提取每张图片中的特征,最后就会形成「认知」。 想一想,你还小的时候,是如何分辨鸭子和鹅的呢? 是不是根据它们的特征进行判断的?

学习和编程实现任务需要的条件

了解完需求和步骤之后,我们还需要准备一些条件:

  • 首先,你必须是一名 IT 工程师。
  • 然后你有一定的数学和统计学习基础。
  • 你还得了解计算机处理图像的方式。
  • 如果图片较多,你需要一台拥有较高算力 GPU 的计算机,否则计算机的「学习」速度会非常慢。

具备以上条件后,再通过短时间(几天或一周)的学习,我们就能够完成图像分类的任务。 讨论个额外的话题,人人都能够做 AI 工程师吗? AI 的门槛是比较高的,首先得具备高等数学、统计学习和编程等基础,然后要有很强的学习能力。对于 IT 工程师来说:

  • 编程基础是没有问题的
  • 学习能力看个人,但花时间、下功夫肯定会有进步
  • 高等数学基础,得好好补
  • 统计学习基础,也得好好补
  • 经济上无压力

如果你想要成为一名 AI 工程师,那么「高学历」几乎是必备的。无论是一线互联网企业或者新崛起的 AI 独角兽,它们为 AI 工程师设立的学历门槛都是「硕士」。除非特别优秀的、才华横溢的大专或本科生,否则是不可能有机会进入这样的企业做 AI 工程师的。 AI 在硬件、软件、数据资料和人才方面都是很费钱的,普通的 IT 工程师也就是学习了解一下,远远达不到产品商用的要求。 普通的中小企业,极少有资质和经济能力吸引高学历且优秀的 AI 工程师,这就导致了资源的聚拢和倾斜。 想要将图像分类技术商用,在让计算机经历「看」、「认识」的步骤并拥有「分辨」能力后,还要将其转换为 Web 服务。 但我只想将人脸识别或者图像分类的功能集成到我的项目当中,就那么困难吗? 我只是一个很小的企业,想要在原来普通的视频监控系统中增加「家人识别」、「陌生人警报」、「火灾警报」和「生物闯入提醒」等功能,没有上述的条件和经济投入,就不能实现了吗? 我好苦恼! 有什么好办法吗?

ModelArts 简介和条件

ModelArts 是华为云推出的产品,它是面向开发者的一站式 AI 开发平台。 它为机器学习与深度学习提供海量数据预处理及半自动化标注、大规模分布式 Training、自动化模型生成,及端-边-云模型按需部署能力,帮助用户快速创建和部署模型,管理全周期 AI 工作流。 它为用户提供了以下可选模式:

  • 零编码经验、零 AI 经验的自动学习模式
  • 有 AI 研发经验的全流程开发模式

同时,它将 AI 开发的整个过程都集成了进来。例如数据标注、模型训练、参数优化、服务部署、开放接口等,这就是「全周期 AI 工作流」。 还有,平台上的操作都是可视化的。 这些条件对于想要将 AI 技术应用于产品,但无奈条件不佳的个人开发者和企业提供了机会,这很重要!可以说 ModelArts) 缩短了 AI 商用的时间,降低了对应的经济成本、时间成本和人力成本。 更贴心的是,华为云 ModelArts) 为用户准备了很多的教程。即使用户没有经验,但只要按照教程指引进行操作,也能够实现自己的 AI 需求。 想想就美滋滋,太棒了! 赶紧体验一下!

图像分类服务实践

这次我们以零 AI 基础和零编码经验的自动学习模式演示如何搭建一个图像分类的 AI 服务。

前期准备和相关设置

首先打开华为云官网,将鼠标移动导航栏的「EI 企业智能」菜单上,并在弹出的选项中选择「AI 开发平台 ModelArts」。 进入到 ModelArts) 主页后,可以浏览一下关于 ModelArts) 的介绍。 点击 Banner 处的「进入控制台」按钮,页面会跳转到 ModelArts 控制台。控制台大体分为几个区域: 区域 2 自动学习模式中有图像分类,将鼠标移动到图标上,并点击弹出的「开始体验」按钮。如果是华为云的新用户,网页会提示我们输入访问密钥和私有访问密钥。 没有密钥的开发者可以点击页面给出的链接并按照指引获取密钥,得到两种密钥后将其填入框中,点击「确定」按钮即可。 此时正式进入项目创建流程中,点击「图像分类」中的「创建项目」按钮(华为云为用户准备了对应的教程,很贴心)。 在创建项目的页面中,我们需要填两三项配置。要注意的是,项目是按需计费的,这次我们只是体验,也没有训练和存储太多数据,所以费用很低,大家不用担心。 项目名称可以根据需求设定一个容易记的,案例中我将其设定为 ImageCLF-Test-Pro。在训练数据的存储选择处,点击输入框中的文件夹图标,在弹出的选项卡中新建 obs 桶 并在创建的桶中新建文件夹 最后输入描述,并点击页面右下角的「创建项目」按钮即可。

上传图片和标注

项目创建好之后,我们需要准备用于训练的多张图片,图片尽量清晰、种类超过 2 类、每种分类的图片数量不少于 5 张。 当然,数据越多、形态越丰富、标注越准确,那么训练结果就会越好,AI 服务的体验就会越好。 这里我准备了一些直升机、坦克和狗的图片,共 45 张。 将其批量导入后勾选同类型的图片,一次性为多张图添加标签。 依次将 3 类图片标注后,左侧图片标注的「未标注」选项卡中的图就会清空,而「已标注」选项卡中可以看到标注好的图片。

训练设置

右侧的标签栏会显示每种分类和对应的图片数量,下方的训练设置可以让我们设置训练时长的上限,高级设置中还有推理时间。 这个我们不必理解它的作用,可以按照默认值进行,也可以稍微调整,例如将训练时长的上限改为 0.2。

开始训练

设置好后点击「开始训练」按钮就会进入训练状态,耐心等待一段时间(图片越少训练时间越短)。 训练页左侧会显示训练状态,例如初始化、运行中和运行成功/失败等。训练完成后,右侧会给出运行时长、准确率、评估结果和训练参数等信息。

服务的自动化部署

我们的目的是搭建一个图像分类的 AI 服务,所以在训练结束后点击左侧的「部署」按钮,此时会进入自动化部署的流程。 稍微等待些许时间(本次约 10 分钟)后,页面提示部署完成,同时页面将会分为 3 栏。 左侧 1 区为部署状态和控制。中间 2 区可以在线测试图片分类,右侧 3 区会显示在线测试的结果(包括准确率),右侧 4 区提供了 API 接口,方便我们将其集成到 Web 应用当中。

在线预测,训练结果测试

我们来测试一下,准备几张没有经过标注的图片,图片中可以包含狗、直升机和坦克。点击中间 2 区的「上传」按钮并选择一张图片,然后点击「预测」按钮。 1 秒中不到,右侧 3 区就会返回本次预测的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"predicted_label": "狗",
"scores": [
[
"狗",
"0.840"
],
[
"直升机",
"0.084"
],
[
"坦克",
"0.076"
]
]
}

这次我们上传的是包含狗的图片,返回的预测结果中显示本次预测的标签是「狗」,并且列出了可信度较高的几个类别和对应的可信度(1 为 100% 肯定),其中最高的是 「0.840-狗」。 这次上传直升机的图片试试。 返回的预测结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"predicted_label": "直升机",
"scores": [
[
"直升机",
"0.810"
],
[
"狗",
"0.114"
],
[
"坦克",
"0.075"
]
]
}

再试试坦克 返回的预测结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"predicted_label": "坦克",
"scores": [
[
"坦克",
"0.818"
],
[
"狗",
"0.092"
],
[
"直升机",
"0.090"
]
]
}

从几次测试的结果可以看出,预测的结果非常准确,而且给出的可信度也比较高。这次准备的图片并不是很多,形态也不是很丰富,但预测效果却非常好,不得不说华为云 ModelArts 开发团队为此做了很多的优化,甚至比我自己(深度学习入门水平)编写代码用卷积神经网络训练和预测的结果要好。 如果想要将其集成到 Web 应用中,只需要根据页面给出的「接口调用指南」的指引进行操作即可。

释放资源

如果不是真正商用,仅仅作为学习和练习,那么在操作完成后记得点击左侧 1 区的「停止」按钮。然后在华为云导航栏中的搜索框输入「OBS」,点击搜索结果后跳转到 OBS 主页,接着再 OBS 主页点击「管理控制台」,进入到 OBS 控制台中,删除之前创建的桶即可。这样就不会导致资源占用,也不会产生费用了。

小结

体验了一下 ModelArts,我感觉非常奈斯! 每处都有提示或教程指引,操作过程流畅,没有出现卡顿、报错等问题。 批量数据标注太好用了!批量导入、批量标注,自动计数,舒服! 训练速度很快,应该是用了云 GPU,这样就算我的电脑没有显卡也能够快速完成训练。 以前还在考虑,学习 AI 是否需要准备更强的硬件设备,现在好了,在 ModelArts 上操作,就不用考虑这些条件了。 本次我们体验的是自动学习,也就是简洁易用的傻瓜式操作。对于专业的 AI 工程师来说,可以选择全流程开发模式。批量数据标注、本地代码编写、本地调试、云端训练、云端部署等一气呵成。 棒! 有兴趣的开发者可以前往华为云 ModelArts) 体验。


备注:文中配图均出自互联网,通过搜索引擎而来。