0%

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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)

因为我们对接的是新浪微博,前面我们已经破解了新浪微博的四宫格验证码,在这里我们直接对接过来即可,不过现在需要加一个获取 Cookies 的方法,并针对不同的情况返回不同的结果,逻辑如下所示:

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
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': ' 登录失败 '
}

这里返回结果的类型是字典,并且附有状态码 status,在生成模块里我们可以根据不同的状态码做不同的处理。例如状态码为 1 的情况,表示成功获取 Cookies,我们只需要将 Cookies 保存到数据库即可。如状态码为 2 的情况,代表用户名或密码错误,那么我们就应该把当前数据库中存储的账号信息删除。如状态码为 3 的情况,则代表登录失败的一些错误,此时不能判断是否用户名或密码错误,也不能成功获取 Cookies,那么简单提示再进行下一个处理即可,类似代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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.accounts_db.delete(username):
print(' 成功删除账号 ')
else:
print(result.get('content'))

如果要扩展其他站点,只需要实现 new_cookies() 方法即可,然后按此处理规则返回对应的模拟登录结果,比如 1 代表获取成功,2 代表用户名或密码错误。 代码运行之后就会遍历一次尚未生成 Cookies 的账号,模拟登录生成新的 Cookies。

检测模块

我们现在可以用生成模块来生成 Cookies,但还是免不了 Cookies 失效的问题,例如时间太长导致 Cookies 失效,或者 Cookies 使用太频繁导致无法正常请求网页。如果遇到这样的 Cookies,我们肯定不能让它继续保存在数据库里。 所以我们还需要增加一个定时检测模块,它负责遍历池中的所有 Cookies,同时设置好对应的检测链接,我们用一个个 Cookies 去请求这个链接。如果请求成功,或者状态码合法,那么该 Cookies 有效;如果请求失败,或者无法获取正常的数据,比如直接跳回登录页面或者跳到验证页面,那么此 Cookies 无效,我们需要将该 Cookies 从数据库中移除。 此 Cookies 移除之后,刚才所说的生成模块就会检测到 Cookies 的 Hash 和账号的 Hash 相比少了此账号的 Cookies,生成模块就会认为这个账号还没生成 Cookies,那么就会用此账号重新登录,此账号的 Cookies 又被重新更新。 检测模块需要做的就是检测 Cookies 失效,然后将其从数据中移除。 为了实现通用可扩展性,我们首先定义一个检测器的父类,声明一些通用组件,实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
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)

在这里定义了一个父类叫作 ValidTester,在init() 方法里指定好站点的名称 website,另外建立两个存储模块连接对象 cookies_db 和 accounts_db,分别负责操作 Cookies 和账号的 Hash,run() 方法是入口,在这里是遍历了所有的 Cookies,然后调用 test() 方法进行测试,在这里 test() 方法是没有实现的,也就是说我们需要写一个子类来重写这个 test() 方法,每个子类负责各自不同网站的检测,如检测微博的就可以定义为 WeiboValidTester,实现其独有的 test() 方法来检测微博的 Cookies 是否合法,然后做相应的处理,所以在这里我们还需要再加一个子类来继承这个 ValidTester,重写其 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
27
28
29
30
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)

test() 方法首先将 Cookies 转化为字典,检测 Cookies 的格式,如果格式不正确,直接将其删除,如果格式没问题,那么就拿此 Cookies 请求被检测的 URL。test() 方法在这里检测微博,检测的 URL 可以是某个 Ajax 接口,为了实现可配置化,我们将测试 URL 也定义成字典,如下所示:

1
TEST_URL_MAP = {'weibo': 'https://m.weibo.cn/'}

如果要扩展其他站点,我们可以统一在字典里添加。对微博来说,我们用 Cookies 去请求目标站点,同时禁止重定向和设置超时时间,得到响应之后检测其返回状态码。如果直接返回 200 状态码,则 Cookies 有效,否则可能遇到了 302 跳转等情况,一般会跳转到登录页面,则 Cookies 已失效。如果 Cookies 失效,我们将其从 Cookies 的 Hash 里移除即可。

接口模块

生成模块和检测模块如果定时运行就可以完成 Cookies 实时检测和更新。但是 Cookies 最终还是需要给爬虫来用,同时一个 Cookies 池可供多个爬虫使用,所以我们还需要定义一个 Web 接口,爬虫访问此接口便可以取到随机的 Cookies。我们采用 Flask 来实现接口的搭建,代码如下所示:

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 json
from flask import Flask, g
app = Flask(__name__)
# 生成模块的配置字典
GENERATOR_MAP = {'weibo': 'WeiboCookiesGenerator'}
@app.route('/')
def index():
return '<h2>Welcome to Cookie Pool System</h2>'

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('/<website>/random')
def random(website):
"""
获取随机的 Cookie, 访问地址如 /weibo/random
:return: 随机 Cookie
"""
g = get_conn()
cookies = getattr(g, website + '_cookies').random()
return cookies

我们同样需要实现通用的配置来对接不同的站点,所以接口链接的第一个字段定义为站点名称,第二个字段定义为获取的方法,例如,/weibo/random 是获取微博的随机 Cookies,/zhihu/random 是获取知乎的随机 Cookies。

调度模块

最后,我们再加一个调度模块让这几个模块配合运行起来,主要的工作就是驱动几个模块定时运行,同时各个模块需要在不同进程上运行,实现如下所示:

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
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
3
4
5
# 产生模块类,如扩展其他站点,请在此配置
GENERATOR_MAP = {'weibo': 'WeiboCookiesGenerator'}

# 测试模块类,如扩展其他站点,请在此配置
TESTER_MAP = {'weibo': 'WeiboValidTester'}

这样的配置是为了方便动态扩展使用的,键名为站点名称,键值为类名。如需要配置其他站点可以在字典中添加,如扩展知乎站点的产生模块,则可以配置成:

1
2
3
4
GENERATOR_MAP = {
'weibo': 'WeiboCookiesGenerator',
'zhihu': 'ZhihuCookiesGenerator',
}

Scheduler 里将字典进行遍历,同时利用 eval() 动态新建各个类的对象,调用其入口 run() 方法运行各个模块。同时,各个模块的多进程使用了 multiprocessing 中的 Process 类,调用其 start() 方法即可启动各个进程。 另外,各个模块还设有模块开关,我们可以在配置文件中自由设置开关的开启和关闭,如下所示:

1
2
3
4
5
6
# 产生模块开关
GENERATOR_PROCESS = True
# 验证模块开关
VALID_PROCESS = False
# 接口模块开关
API_PROCESS = True

定义为 True 即可开启该模块,定义为 False 即关闭此模块。 至此,我们的 Cookies 就全部完成了。接下来我们将模块同时开启,启动调度器,控制台类似输出如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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) 体验。


备注:文中配图均出自互联网,通过搜索引擎而来。

Python

前面讲解了代理池的维护和付费代理的相关使用方法,接下来我们进行一下实战演练,利用代理来爬取微信公众号的文章。

1. 本节目标

我们的主要目标是利用代理爬取微信公众号的文章,提取正文、发表日期、公众号等内容,爬取来源是搜狗微信,其链接为 http://weixin.sogou.com/,然后把爬取结果保存到 MySQL 数据库。

2. 准备工作

首先需要准备并正常运行前文中所介绍的代理池。这里需要用的 Python 库有 aiohttp、requests、redis-py、pyquery、Flask、PyMySQL,如这些库没有安装可以参考第 1 章的安装说明。

3. 爬取分析

搜狗对微信公众平台的公众号和文章做了整合。我们可以通过上面的链接搜索到相关的公众号和文章,例如搜索 NBA,可以搜索到最新的文章,如图 9-21 所示。 图 9-21 搜索结果 点击搜索后,搜索结果的 URL 中其实有很多无关 GET 请求参数,将无关的参数去掉,只保留 type 和 query 参数,例如 http://weixin.sogou.com/weixin?type=2&query=NBA,搜索关键词为 NBA,类型为 2,2 代表搜索微信文章。 下拉网页,点击下一页即可翻页,如图 9-22 所示。 图 9-22 翻页列表 注意,如果没有输入账号登录,那只能看到 10 页的内容,登录之后可以看到 100 页内容,如图 9-23 和图 9-24 所示。 图 9-23 不登录的结果 图 9-24 登录后的结果 如果需要爬取更多内容,就需要登录并使用 Cookies 来爬取。 搜狗微信站点的反爬虫能力很强,如连续刷新,站点就会弹出类似如图 9-25 所示的验证。 图 9-25 验证码页面 网络请求出现了 302 跳转,返回状态码为 302,跳转的链接开头为 http://weixin.sogou.com/antispider/,这很明显就是一个反爬虫的验证页面。所以我们得出结论,如果服务器返回状态码为 302 而非 200,则 IP 访问次数太高,IP 被封禁,此请求就是失败了。 如果遇到这种情况,我们可以选择识别验证码并解封,也可以使用代理直接切换 IP。在这里我们采用第二种方法,使用代理直接跳过这个验证。代理使用上一节所讲的代理池,还需要更改检测的 URL 为搜狗微信的站点。 对于这种反爬能力很强的网站来说,如果我们遇到此种返回状态就需要重试。所以我们采用另一种爬取方式,借助数据库构造一个爬取队列,待爬取的请求都放到队列里,如果请求失败了重新放回队列,就会被重新调度爬取。 在这里我们可以采用 Redis 的队列数据结构,新的请求就加入队列,或者有需要重试的请求也放回队列。调度的时候如果队列不为空,那就把一个个请求取出来执行,得到响应后再进行解析,提取出我们想要的结果。 这次我们采用 MySQL 存储,借助 PyMySQL 库,将爬取结果构造为一个字典,实现动态存储。 综上所述,我们本节实现的功能有如下几点。

  • 修改代理池检测链接为搜狗微信站点
  • 构造 Redis 爬取队列,用队列实现请求的存取
  • 实现异常处理,失败的请求重新加入队列
  • 实现翻页和提取文章列表并把对应请求加入队列
  • 实现微信文章的信息的提取
  • 将提取到的信息保存到 MySQL

好,那么接下来我们就用代码来实现一下。

4. 构造 Request

既然我们要用队列来存储请求,那么肯定要实现一个请求 Request 的数据结构,这个请求需要包含一些必要信息,如请求链接、请求头、请求方式、超时时间。另外对于某个请求,我们需要实现对应的方法来处理它的响应,所以需要再加一个 Callback 回调函数。每次翻页请求需要代理来实现,所以还需要一个参数 NeedProxy。如果一个请求失败次数太多,那就不再重新请求了,所以还需要加失败次数的记录。 这些字段都需要作为 Request 的一部分,组成一个完整的 Request 对象放入队列去调度,这样从队列获取出来的时候直接执行这个 Request 对象就好了。 我们可以采用继承 reqeusts 库中的 Request 对象的方式来实现这个数据结构。requests 库中已经有了 Request 对象,它将请求 Request 作为一个整体对象去执行,得到响应后再返回。其实 requests 库的 get()、post() 等方法都是通过执行 Request 对象实现的。 我们首先看看 Request 对象的源码:

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
class Request(RequestHooksMixin):
def __init__(self,
method=None, url=None, headers=None, files=None, data=None,
params=None, auth=None, cookies=None, hooks=None, json=None):

# Default empty dicts for dict params.
data = [] if data is None else data
files = [] if files is None else files
headers = {} if headers is None else headers
params = {} if params is None else params
hooks = {} if hooks is None else hooks

self.hooks = default_hooks()
for (k, v) in list(hooks.items()):
self.register_hook(event=k, hook=v)

self.method = method
self.url = url
self.headers = headers
self.files = files
self.data = data
self.json = json
self.params = params
self.auth = auth
self.cookies = cookies

这是 requests 库中 Request 对象的构造方法。这个 Request 已经包含了请求方式、请求链接、请求头这几个属性,但是相比我们需要的还差了几个。我们需要实现一个特定的数据结构,在原先基础上加入上文所提到的额外几个属性。这里我们需要继承 Request 对象重新实现一个请求,将它定义为 WeixinRequest,实现如下:

1
2
3
4
5
6
7
8
9
10
TIMEOUT = 10
from requests import Request

class WeixinRequest(Request):
def __init__(self, url, callback, method='GET', headers=None, need_proxy=False, fail_time=0, timeout=TIMEOUT):
Request.__init__(self, method, url, headers)
self.callback = callback
self.need_proxy = need_proxy
self.fail_time = fail_time
self.timeout = timeout

在这里我们实现了 WeixinRequest 数据结构。init() 方法先调用了 Request 的init() 方法,然后加入额外的几个参数,定义为 callback、need_proxy、fail_time、timeout,分别代表回调函数、是否需要代理爬取、失败次数、超时时间。 我们就可以将 WeixinRequest 作为一个整体来执行,一个个 WeixinRequest 对象都是独立的,每个请求都有自己的属性。例如,我们可以调用它的 callback,就可以知道这个请求的响应应该用什么方法来处理,调用 fail_time 就可以知道这个请求失败了多少次,判断失败次数是不是到了阈值,该不该丢弃这个请求。这里我们采用了面向对象的一些思想。

5. 实现请求队列

接下来我们就需要构造请求队列,实现请求的存取。存取无非就是两个操作,一个是放,一个是取,所以这里利用 Redis 的 rpush() 和 lpop() 方法即可。 另外还需要注意,存取不能直接存 Request 对象,Redis 里面存的是字符串。所以在存 Request 对象之前我们先把它序列化,取出来的时候再将其反序列化,这个过程可以利用 pickle 模块实现。

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
from pickle import dumps, loads
from request import WeixinRequest

class RedisQueue():
def __init__(self):
"""初始化 Redis"""
self.db = StrictRedis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD)

def add(self, request):
"""
向队列添加序列化后的 Request
:param request: 请求对象
:param fail_time: 失败次数
:return: 添加结果
"""
if isinstance(request, WeixinRequest):
return self.db.rpush(REDIS_KEY, dumps(request))
return False

def pop(self):
"""
取出下一个 Request 并反序列化
:return: Request or None
"""
if self.db.llen(REDIS_KEY):
return loads(self.db.lpop(REDIS_KEY))
else:
return False

def empty(self):
return self.db.llen(REDIS_KEY) == 0

这里实现了一个 RedisQueue,它的 init() 构造方法里面初始化了一个 StrictRedis 对象。随后实现了 add() 方法,首先判断 Request 的类型,如果是 WeixinRequest,那么就把程序就会用 pickle 的 dumps() 方法序列化,然后再调用 rpush() 方法加入队列。pop() 方法则相反,调用 lpop() 方法将请求从队列取出,然后再用 pickle 的 loads() 方法将其转为 WeixinRequest 对象。另外,empty() 方法返回队列是否为空,只需要判断队列长度是否为 0 即可。 在调度的时候,我们只需要新建一个 RedisQueue 对象,然后调用 add() 方法,传入 WeixinRequest 对象,即可将 WeixinRequest 加入队列,调用 pop() 方法,即可取出下一个 WeixinRequest 对象,非常简单易用。

6. 修改代理池

接下来我们要生成请求并开始爬取。在此之前还需要做一件事,那就是先找一些可用代理。 之前代理池检测的 URL 并不是搜狗微信站点,所以我们需要将代理池检测的 URL 修改成搜狗微信站点,以便于把被搜狗微信站点封禁的代理剔除掉,留下可用代理。 现在将代理池的设置文件中的 TEST_URL 修改一下,如 http://weixin.sogou.com/weixin?type=2&amp; query=nba,被本站点封的代理就会减分,正常请求的代理就会赋值为 100,最后留下的就是可用代理。 修改之后将获取模块、检测模块、接口模块的开关都设置为 True,让代理池运行一会,如图 9-26 所示。 图 9-26 代理池运行结果 这样,数据库中留下的 100 分的代理就是针对搜狗微信的可用代理了,如图 9-27 所示。 图 9-27 可用代理列表 同时访问代理接口,接口设置为 5555,访问 http://127.0.0.1:5555/random,即可获取到随机可用代理,如图 9-28 所示。 图 9-28 代理接口 再定义一个函数来获取随机代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PROXY_POOL_URL = 'http://127.0.0.1:5555/random'

def get_proxy(self):
"""
从代理池获取代理
:return:
"""
try:
response = requests.get(PROXY_POOL_URL)
if response.status_code == 200:
print('Get Proxy', response.text)
return response.text
return None
except requests.ConnectionError:
return None

7. 第一个请求

一切准备工作都做好,下面我们就可以构造第一个请求放到队列里以供调度了。定义一个 Spider 类,实现 start() 方法的代码如下:

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
from requests import Session
from db import RedisQueue
from request import WeixinRequest
from urllib.parse import urlencode

class Spider():
base_url = 'http://weixin.sogou.com/weixin'
keyword = 'NBA'
headers = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'zh-CN,zh;q=0.8,en;q=0.6,ja;q=0.4,zh-TW;q=0.2,mt;q=0.2',
'Cache-Control': 'max-age=0',
'Connection': 'keep-alive',
'Cookie': 'IPLOC=CN1100; SUID=6FEDCF3C541C940A000000005968CF55; SUV=1500041046435211; ABTEST=0|1500041048|v1; SNUID=CEA85AE02A2F7E6EAFF9C1FE2ABEBE6F; weixinIndexVisited=1; JSESSIONID=aaar_m7LEIW-jg_gikPZv; ld=Wkllllllll2BzGMVlllllVOo8cUlllll5G@HbZllll9lllllRklll5@@@@@@@@@@',
'Host': 'weixin.sogou.com',
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36'
}
session = Session()
queue = RedisQueue()

def start(self):
"""初始化工作"""
# 全局更新 Headers
self.session.headers.update(self.headers)
start_url = self.base_url + '?' + urlencode({'query': self.keyword, 'type': 2})
weixin_request = WeixinRequest(url=start_url, callback=self.parse_index, need_proxy=True)
# 调度第一个请求
self.queue.add(weixin_request)

这里定义了 Spider 类,设置了很多全局变量,比如 keyword 设置为 NBA,headers 就是请求头。在浏览器里登录账号,然后在开发者工具里将请求头复制出来,记得带上 Cookie 字段,这样才能爬取 100 页的内容。然后初始化了 Session 和 RedisQueue 对象,它们分别用来执行请求和存储请求。 首先,start() 方法全局更新了 headers,使得所有请求都能应用 Cookies。然后构造了一个起始 URL:http://weixin.sogou.com/weixin?type=2&query=NBA,随后用改 URL 构造了一个 WeixinRequest 对象。回调函数是 Spider 类的 parse_index() 方法,也就是当这个请求成功之后就用 parse_index() 来处理和解析。need_proxy 参数设置为 True,代表执行这个请求需要用到代理。随后我们调用了 RedisQueue 的 add() 方法,将这个请求加入队列,等待调度。

8. 调度请求

加入第一个请求之后,调度开始了。我们首先从队列中取出这个请求,将它的结果解析出来,生成新的请求加入队列,然后拿出新的请求,将结果解析,再生成新的请求加入队列,这样循环往复执行,直到队列中没有请求,则代表爬取结束。我们用代码实现如下:

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
VALID_STATUSES = [200]

def schedule(self):
"""
调度请求
:return:
"""
while not self.queue.empty():
weixin_request = self.queue.pop()
callback = weixin_request.callback
print('Schedule', weixin_request.url)
response = self.request(weixin_request)
if response and response.status_code in VALID_STATUSES:
results = list(callback(response))
if results:
for result in results:
print('New Result', result)
if isinstance(result, WeixinRequest):
self.queue.add(result)
if isinstance(result, dict):
self.mysql.insert('articles', result)
else:
self.error(weixin_request)
else:
self.error(weixin_request)

在这里实现了一个 schedule() 方法,其内部是一个循环,循环的判断是队列不为空。 当队列不为空时,调用 pop() 方法取出下一个请求,调用 request() 方法执行这个请求,request() 方法的实现如下:

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

def request(self, weixin_request):
"""
执行请求
:param weixin_request: 请求
:return: 响应
"""
try:
if weixin_request.need_proxy:
proxy = get_proxy()
if proxy:
proxies = {
'http': 'http://' + proxy,
'https': 'https://' + proxy
}
return self.session.send(weixin_request.prepare(),
timeout=weixin_request.timeout, allow_redirects=False, proxies=proxies)
return self.session.send(weixin_request.prepare(), timeout=weixin_request.timeout, allow_redirects=False)
except (ConnectionError, ReadTimeout) as e:
print(e.args)
return False

这里首先判断这个请求是否需要代理,如果需要代理,则调用 get_proxy() 方法获取代理,然后调用 Session 的 send() 方法执行这个请求。这里的请求调用了 prepare() 方法转化为 Prepared Request,具体的用法可以参考 http://docs.python-requests.org/en/master/user/advanced/#prepared-requests,同时设置 allow_redirects 为 False,timeout 是该请求的超时时间,最后响应返回。 执行 request() 方法之后会得到两种结果:一种是 False,即请求失败,连接错误;另一种是 Response 对象,还需要判断状态码,如果状态码合法,那么就进行解析,否则重新将请求加回队列。 如果状态码合法,解析的时候就会调用 WeixinRequest 的回调函数进行解析。比如这里的回调函数是 parse_index(),其实现如下:

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

def parse_index(self, response):
"""
解析索引页
:param response: 响应
:return: 新的响应
"""
doc = pq(response.text)
items = doc('.news-box .news-list li .txt-box h3 a').items()
for item in items:
url = item.attr('href')
weixin_request = WeixinRequest(url=url, callback=self.parse_detail)
yield weixin_request
next = doc('#sogou_next').attr('href')
if next:
url = self.base_url + str(next)
weixin_request = WeixinRequest(url=url, callback=self.parse_index, need_proxy=True)
yield weixin_request

此方法做了两件事:一件事就是获取本页的所有微信文章链接,另一件事就是获取下一页的链接,再构造成 WeixinRequest 之后 yield 返回。 然后,schedule() 方法将返回的结果进行遍历,利用 isinstance() 方法判断返回结果,如果返回结果是 WeixinRequest,就将其重新加入队列。 至此,第一次循环结束。 这时 while 循环会继续执行。队列已经包含第一页内容的文章详情页请求和下一页的请求,所以第二次循环得到的下一个请求就是文章详情页的请求,程序重新调用 request() 方法获取其响应,然后调用其对应的回调函数解析。这时详情页请求的回调方法就不同了,这次是 parse_detail() 方法,此方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
def parse_detail(self, response):
"""
解析详情页
:param response: 响应
:return: 微信公众号文章
"""
doc = pq(response.text)
data = {'title': doc('.rich_media_title').text(),
'content': doc('.rich_media_content').text(),
'date': doc('#post-date').text(),
'nickname': doc('#js_profile_qrcode> div > strong').text(),
'wechat': doc('#js_profile_qrcode> div > p:nth-child(3) > span').text()}
yield data

这个方法解析了微信文章详情页的内容,提取出它的标题、正文文本、发布日期、发布人昵称、微信公众号名称,将这些信息组合成一个字典返回。 结果返回之后还需要判断类型,如是字典类型,程序就调用 mysql 对象的 insert() 方法将数据存入数据库。 这样,第二次循环执行完毕。 第三次循环、第四次循环,循环往复,每个请求都有各自的回调函数,索引页解析完毕之后会继续生成后续请求,详情页解析完毕之后会返回结果以便存储,直到爬取完毕。 现在,整个调度就完成了。 我们完善一下整个 Spider 代码,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
from requests import Session
from config import *
from db import RedisQueue
from mysql import MySQL
from request import WeixinRequest
from urllib.parse import urlencode
import requests
from pyquery import PyQuery as pq
from requests import ReadTimeout, ConnectionError

class Spider():
base_url = 'http://weixin.sogou.com/weixin'
keyword = 'NBA'
headers = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'zh-CN,zh;q=0.8,en;q=0.6,ja;q=0.4,zh-TW;q=0.2,mt;q=0.2',
'Cache-Control': 'max-age=0',
'Connection': 'keep-alive',
'Cookie': 'IPLOC=CN1100; SUID=6FEDCF3C541C940A000000005968CF55; SUV=1500041046435211; ABTEST=0|1500041048|v1; SNUID=CEA85AE02A2F7E6EAFF9C1FE2ABEBE6F; weixinIndexVisited=1; JSESSIONID=aaar_m7LEIW-jg_gikPZv; ld=Wkllllllll2BzGMVlllllVOo8cUlllll5G@HbZllll9lllllRklll5@@@@@@@@@@',
'Host': 'weixin.sogou.com',
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36'
}
session = Session()
queue = RedisQueue()
mysql = MySQL()

def get_proxy(self):
"""
从代理池获取代理
:return:
"""
try:
response = requests.get(PROXY_POOL_URL)
if response.status_code == 200:
print('Get Proxy', response.text)
return response.text
return None
except requests.ConnectionError:
return None

def start(self):
"""初始化工作"""
# 全局更新 Headers
self.session.headers.update(self.headers)
start_url = self.base_url + '?' + urlencode({'query': self.keyword, 'type': 2})
weixin_request = WeixinRequest(url=start_url, callback=self.parse_index, need_proxy=True)
# 调度第一个请求
self.queue.add(weixin_request)

def parse_index(self, response):
"""
解析索引页
:param response: 响应
:return: 新的响应
"""
doc = pq(response.text)
items = doc('.news-box .news-list li .txt-box h3 a').items()
for item in items:
url = item.attr('href')
weixin_request = WeixinRequest(url=url, callback=self.parse_detail)
yield weixin_request
next = doc('#sogou_next').attr('href')
if next:
url = self.base_url + str(next)
weixin_request = WeixinRequest(url=url, callback=self.parse_index, need_proxy=True)
yield weixin_request

def parse_detail(self, response):
"""
解析详情页
:param response: 响应
:return: 微信公众号文章
"""
doc = pq(response.text)
data = {'title': doc('.rich_media_title').text(),
'content': doc('.rich_media_content').text(),
'date': doc('#post-date').text(),
'nickname': doc('#js_profile_qrcode> div > strong').text(),
'wechat': doc('#js_profile_qrcode> div > p:nth-child(3) > span').text()}
yield data

def request(self, weixin_request):
"""
执行请求
:param weixin_request: 请求
:return: 响应
"""
try:
if weixin_request.need_proxy:
proxy = self.get_proxy()
if proxy:
proxies = {
'http': 'http://' + proxy,
'https': 'https://' + proxy
}
return self.session.send(weixin_request.prepare(),
timeout=weixin_request.timeout, allow_redirects=False, proxies=proxies)
return self.session.send(weixin_request.prepare(), timeout=weixin_request.timeout, allow_redirects=False)
except (ConnectionError, ReadTimeout) as e:
print(e.args)
return False

def error(self, weixin_request):
"""
错误处理
:param weixin_request: 请求
:return:
"""
weixin_request.fail_time = weixin_request.fail_time + 1
print('Request Failed', weixin_request.fail_time, 'Times', weixin_request.url)
if weixin_request.fail_time < MAX_FAILED_TIME:
self.queue.add(weixin_request)

def schedule(self):
"""
调度请求
:return:
"""
while not self.queue.empty():
weixin_request = self.queue.pop()
callback = weixin_request.callback
print('Schedule', weixin_request.url)
response = self.request(weixin_request)
if response and response.status_code in VALID_STATUSES:
results = list(callback(response))
if results:
for result in results:
print('New Result', result)
if isinstance(result, WeixinRequest):
self.queue.add(result)
if isinstance(result, dict):
self.mysql.insert('articles', result)
else:
self.error(weixin_request)
else:
self.error(weixin_request)

def run(self):
"""
入口
:return:
"""
self.start()
self.schedule()

if __name__ == '__main__':
spider = Spider()
spider.run()

最后,我们加了一个 run() 方法作为入口,启动的时候只需要执行 Spider 的 run() 方法即可。

9. MySQL 存储

整个调度模块完成了,上面还没提及到的就是存储模块,在这里还需要定义一个 MySQL 类供存储数据,实现如下:

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
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_PASSWORD = 'foobared'
REDIS_KEY = 'weixin'

import pymysql
from config import *

class MySQL():
def __init__(self, host=MYSQL_HOST, username=MYSQL_USER, password=MYSQL_PASSWORD, port=MYSQL_PORT,
database=MYSQL_DATABASE):
"""
MySQL 初始化
:param host:
:param username:
:param password:
:param port:
:param database:
"""
try:
self.db = pymysql.connect(host, username, password, database, charset='utf8', port=port)
self.cursor = self.db.cursor()
except pymysql.MySQLError as e:
print(e.args)

def insert(self, table, data):
"""
插入数据
:param table:
:param data:
:return:
"""
keys = ', '.join(data.keys())
values = ', '.join(['% s'] * len(data))
sql_query = 'insert into % s (% s) values (% s)' % (table, keys, values)
try:
self.cursor.execute(sql_query, tuple(data.values()))
self.db.commit()
except pymysql.MySQLError as e:
print(e.args)
self.db.rollback()

init() 方法初始化了 MySQL 连接,需要 MySQL 的用户、密码、端口、数据库名等信息。数据库名为 weixin,需要自己创建。 insert() 方法传入表名和字典即可动态构造 SQL,在 5.2 节中也有讲到,SQL 构造之后执行即可插入数据。 我们还需要提前建立一个数据表,表名为 articles,建表的 SQL 语句如下:

1
2
3
4
5
6
7
8
CREATE TABLE `articles` (`id` int(11) NOT NULL,
`title` varchar(255) NOT NULL,
`content` text NOT NULL,
`date` varchar(255) NOT NULL,
`wechat` varchar(255) NOT NULL,
`nickname` varchar(255) NOT NULL
) DEFAULT CHARSET=utf8;
ALTER TABLE `articles` ADD PRIMARY KEY (`id`);

现在,我们的整个爬虫就算完成了。

10. 运行

示例运行结果如图 9-29 所示: 图 9-29 运行结果 程序首先调度了第一页结果对应的请求,获取了代理执行此请求,随后得到了 11 个新请求,请求都是 WeixinRequest 类型,将其再加入队列。随后继续调度新加入的请求,也就是文章详情页对应的请求,再执行,得到的就是文章详情对应的提取结果,提取结果是字典类型。 程序循环往复,不断爬取,直至所有结果爬取完毕,程序终止,爬取完成。 爬取结果如图 9-30 所示。 图 9-30 爬取结果 我们可以看到,相关微信文章都已被存储到数据库里了。

11. 本节代码

本节代码地址为:https://github.com/Python3WebSpider/Weixin,运行之前请先配置好代理池。

12. 结语

以上内容便是使用代理爬取微信公众号文章的方法,涉及的新知识点不少,希望大家可以好好消化。

技术杂谈

我们尝试维护过一个代理池。代理池可以挑选出许多可用代理,但是常常其稳定性不高、响应速度慢,而且这些代理通常是公共代理,可能不止一人同时使用,其 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,而且线路非常稳定,抓取效果好很多。

技术杂谈

爬虫是大数据时代不可或缺的数据获取手段,它是综合技术的应用体现。

​有取就有失,有攻就有防。开发者为了保护数据,不得已想出了很多办法来限制爬虫对数据的获取。WEB 网站的构成使得 JavaScript 成为了开发者阻挡爬虫的最佳选择。

作为一名爬虫工程师,解决目标网站设置的反爬虫手段是职责所在。大家遇到的问题都很相似:

1、遇到加密的内容就无从下手,一片迷茫……!

2、会一点 JS 语法,能解一些简单的,但复杂的就不行了!

3、抠代码太繁杂了,根本不知道怎么办,一早上都定位不到函数入口!

4、混淆过后的代码,看得头都痛!

5、咦,这串加密的字符串怎么搞?

需求又如何呢?

爬虫工程师真的需要学习逆向吗?

在此之前我们在多个爬虫工程师群做了调查,投票结果如下:

在招聘方面

很多岗位都要求有逆向或者解决反爬虫的能力 甚至作为优先选择的条件

显然,拥有逆向能力的爬虫工程师的职业等级会变得更高、团队地位更高,薪资自然也更高。

本课程将主要从原理和技巧两个角度来为大家讲解 JavaScript 反爬虫绕过的相关知识。课程从反爬虫原理、工具介绍和使用、JavaScript 基础语法入手,结合常见的反爬虫现象及其绕过实战操作,帮助你掌握中级爬虫工程师必备的 JavaScript 逆向知识,向更高的职级迈进!

你将从本课程中收获什么?

序号

内容

重要程度

1

深入理解 JavaScript 反爬虫的根本原因

A+

2

了解工作中常用的 JavaScript 语法和知识

B

3

掌握各种逆向神器的基本使用和骚操作

A

4

深入理解 JavaScript 代码混淆的原理

A+

5

掌握 JavaScript 中常见的编码和加密方法

A

6

拥有快速定位加密代码位置的能力

A

7

轻松面对各种各样的加密字符串

A

学习案例的制作思路均来自实际网站在用的反爬虫手段,当你学习完整套课程后就可以独立面对前端反爬虫问题,平时的苦恼也将迎刃而解。

课程大纲设计

序号

标签

课程标题

1

追根究底

探寻 JavaScript 反爬虫的根本原因

2

浮沙之上

课程中用到的 JavaScript 语法和知识

3

奇门遁甲

使用 Python 执行 JavaScript

4

蓄势待发

浏览器开发者工具的介绍和使用技巧

5

磨刀霍霍

抓包和拦截工具的介绍和使用技巧

6

初窥门径

阻挠爬虫工程师的无限 debug

7

火眼金睛

定位加密参数对应代码位置的方法

8

拨开云雾

代码混淆的原理

9

一击即中

处理代码混淆的方法

10

知己知彼

掌握常见的编码和加密

11

长枪直入

轻松解决反人类的混淆代码

12

一叶障目

服务端返回的神秘字符串

13

螳臂当车

解密!AES 并不是每次都奏效

14

插翅难逃

纵然 CSS 加身也难逃命运的安排

15

真假猴王

Base64 竟有如此威力

16

过眼云烟

历练半生 归来仍是少年

备注:具体开课时的目录有可能与现在的大纲存在差异,但改动不大。 这课程大纲预售放出来,我们都不怕别的机构抄,随便它们模仿。

课程特色和内容制作团队介绍

自研练习平台,不触碰法律红线、练习案例不会过期。

我们没有xx顶级讲师,也没有国外xx计算机硕士博士,不存在的。

我们只有一线爬虫工程师,讲最实用的内容,做最有效的练习。团队成员包括:崔庆才、韦世东、陈祥安、张冶青、唐轶飞、蔡晋、冯威、戴煌金、周子淇。

不像在线课平台,加群后讲师根本没空回答问题。我们会持续跟进与交流,制作良心内容,恶心营销狗?干的事我们才不会干。

团队成员有图书出版经历,例如崔庆才的 IT 畅销书《Python3 网络爬虫开发实战》、韦世东即将出版的《Python3 反爬虫原理与绕过实战》。

团队水平如何请大家自行斟酌,这里我就不吹嘘什么了。

预售活动

课程在准备当中,现在开放预售。完整课程售价 399,预售 50 元抵正式课程 100 元,且可提前进入视频教程的微信交流群,甚至可以提出想看的内容,团队会酌情考虑加课。

预售目标为 1000 人,不足 1000 人预售取消,逐个退款。

预售成功后,开课之前可以申请退课退款,支持全额退款。

参与预售的朋友还可以参与开课前的营销活动。

未参与预售的朋友,只能在开课时按原价或活动价购买课程。

预售活动截止日期为 2019年10月31日。

开课时间

课程在准备当中,预计 2 个月左右可看,最迟 2020年01月20日 你就能看到了!

不过可以肯定的是,报名人数越多,内容制作越快。

如何报名

添加微信号:Domfreez,或者扫描下方二维码,与夜幕韦世东聊一聊。 我为粉丝争取到了额外的 50 元优惠,预售期间内主动出示优惠码:GERMEY01 即可在活动基础上再减 50 元! 相信我没错的,现在你只需要花 50 元预订,399 元的课程就抵扣了 150,正式开售的时候只需要再付 249 即可学习,从此前端反爬虫不再是烦恼! 声明: 本次活动最终解释权归内容制作方夜幕团队所有。

技术杂谈

本文为转载文章,旨在记录一些有用的知识点。

1. 概述

JSON-RPC 是一个无状态且轻量级的远程过程调用 (RPC) 协议。 本规范主要定义了一些数据结构及其相关的处理规则。它允许运行在基于 socket, http 等诸多不同消息传输环境的同一进程中。其使用 JSONRFC 4627)作为数据格式。 它为简单而生!

2. 约定

文档中关键字 “MUST”、”MUST NOT”、”REQUIRED”、”SHALL”、”SHALL NOT”、”SHOULD”、”SHOULD NOT”、”RECOMMENDED”、”MAY” 和 “OPTIONAL” 将在 RFC 2119 中得到详细的解释及描述。 由于 JSON-RPC 使用 JSON,它具有与其相同的类型系统 (见 http://www.json.orgRFC 4627)。JSON 可以表示四个基本类型 (String、Numbers、Booleans 和 Null) 和两个结构化类型 (Objects 和 Arrays)。 规范中,术语 “Primitive” 标记那 4 种原始类型,“Structured” 标记两种结构化类型。任何时候文档涉及 JSON 数据类型,第一个字母都必须大写:Object,Array,String,Number,Boolean,Null。包括 True 和 False 也要大写。 在客户端与任何被匹配到的服务端之间交换的所有成员名字应是区分大小写的。 函数、方法、过程都可以认为是可以互换的。 客户端被定义为请求对象的来源及响应对象的处理程序。 服务端被定义为响应对象的起源和请求对象的处理程序。 该规范的一种实现为可以轻而易举的填补这两个角色,即使是在同一时间,同一客户端或其他不相同的客户端。 该规范不涉及复杂层。

3. 兼容性

JSON-RPC 2.0 的请求对象和响应对象可能无法在现用的 JSON-RPC 1.0 客户端或服务端工作,然而我们可以很容易在两个版本间区分出 2.0,总会包含一个成员命名为 “jsonrpc” 且值为 “2.0”, 而 1.0 版本是不包含的。大部分的 2.0 实现应该考虑尝试处理 1.0 的对象,即使不是对等的也应给其相关提示。

4. 请求对象

发送一个请求对象至服务端代表一个 rpc 调用, 一个请求对象包含下列成员: jsonrpc

指定 JSON-RPC 协议版本的字符串,必须准确写为 “2.0”

method

包含所要调用方法名称的字符串,以 rpc 开头的方法名,用英文句号(U+002E or ASCII 46)连接的为预留给 rpc 内部的方法名及扩展名,且不能在其他地方使用。

params

调用方法所需要的结构化参数值,该成员参数可以被省略。

id

已建立客户端的唯一标识 id,值必须包含一个字符串、数值或 NULL 空值。如果不包含该成员则被认定为是一个通知。该值一般不为 NULL [1],若为数值则不应该包含小数 [2]

服务端必须回答相同的值如果包含在响应对象。 这个成员用来两个对象之间的关联上下文。 [1] 在请求对象中不建议使用 NULL 作为 id 值,因为该规范将使用空值认定为未知 id 的请求。另外,由于 JSON-RPC 1.0 的通知使用了空值,这可能引起处理上的混淆。 [2] 使用小数是不确定性的,因为许多十进制小数不能精准的表达为二进制小数。

4.1 通知

没有包含 “id” 成员的请求对象为通知, 作为通知的请求对象表明客户端对相应的响应对象并不感兴趣,本身也没有响应对象需要返回给客户端。服务端必须不回复一个通知,包含那些批量请求中的。 由于通知没有返回的响应对象,所以通知不确定是否被定义。同样,客户端不会意识到任何错误(例如参数缺省,内部错误)。

4.2 参数结构

rpc 调用如果存在参数则必须为基本类型或结构化类型的参数值,要么为索引数组,要么为关联数组对象。

  • 索引:参数必须为数组,并包含与服务端预期顺序一致的参数值。
  • 关联名称:参数必须为对象,并包含与服务端相匹配的参数成员名称。没有在预期中的成员名称可能会引起错误。名称必须完全匹配,包括方法的预期参数名以及大小写。

5. 响应对象

当发起一个 rpc 调用时,除通知之外,服务端都必须回复响应。响应表示为一个 JSON 对象,使用以下成员: jsonrpc

指定 JSON-RPC 协议版本的字符串,必须准确写为 “2.0”

result

该成员在成功时必须包含。 当调用方法引起错误时必须不包含该成员。 服务端中的被调用方法决定了该成员的值。

error

该成员在失败是必须包含。 当没有引起错误的时必须不包含该成员。 该成员参数值必须为 5.1 中定义的对象。

id

该成员必须包含。 该成员值必须于请求对象中的 id 成员值一致。 若在检查请求对象 id 时错误(例如参数错误或无效请求),则该值必须为空值。

响应对象必须包含 result 或 error 成员,但两个成员必须不能同时包含。

5.1 错误对象

当一个 rpc 调用遇到错误时,返回的响应对象必须包含错误成员参数,并且为带有下列成员参数的对象: code

使用数值表示该异常的错误类型。 必须为整数。

message

对该错误的简单描述字符串。 该描述应尽量限定在简短的一句话。

data

包含关于错误附加信息的基本类型或结构化类型。该成员可忽略。 该成员值由服务端定义(例如详细的错误信息,嵌套的错误等)。

-32768 至 - 32000 为保留的预定义错误代码。在该范围内的错误代码不能被明确定义,保留下列以供将来使用。错误代码基本与 XML-RPC 建议的一样,url: http://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php

code

message

meaning

-32700

Parse error 语法解析错误

服务端接收到无效的 json。该错误发送于服务器尝试解析 json 文本

-32600

Invalid Request 无效请求

发送的 json 不是一个有效的请求对象。

-32601

Method not found 找不到方法

该方法不存在或无效

-32602

Invalid params 无效的参数

无效的方法参数。

-32603

Internal error 内部错误

JSON-RPC 内部错误。

-32000 to -32099

Server error 服务端错误

预留用于自定义的服务器错误。

除此之外剩余的错误类型代码可供应用程序作为自定义错误。

6. 批量调用

当需要同时发送多个请求对象时,客户端可以发送一个包含所有请求对象的数组。 当批量调用的所有请求对象处理完成时,服务端则需要返回一个包含相对应的响应对象数组。每个响应对象都应对应每个请求对象,除非是通知的请求对象。服务端可以并发的,以任意顺序和任意宽度的并行性来处理这些批量调用。 这些相应的响应对象可以任意顺序的包含在返回的数组中,而客户端应该是基于各个响应对象中的 id 成员来匹配对应的请求对象。 若批量调用的 rpc 操作本身非一个有效 json 或一个至少包含一个值的数组,则服务端返回的将单单是一个响应对象而非数组。若批量调用没有需要返回的响应对象,则服务端不需要返回任何结果且必须不能返回一个空数组给客户端。

7. 示例

语法:

--> data sent to Server data sent to Client

带索引数组参数的rpc调用:

1
2
3
4
5
\--> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
{"jsonrpc": "2.0", "result": 19, "id": 1}

--> {"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}
{"jsonrpc": "2.0", "result": -19, "id": 2}

带关联数组参数的rpc调用:

1
2
3
4
5
\--> {"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}
{"jsonrpc": "2.0", "result": 19, "id": 3}

--> {"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}
{"jsonrpc": "2.0", "result": 19, "id": 4}

通知:

1
2
\--> {"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}
--> {"jsonrpc": "2.0", "method": "foobar"}

不包含调用方法的rpc调用:

1
2
\--> {"jsonrpc": "2.0", "method": "foobar", "id": "1"}
{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}

包含无效json的rpc调用:

1
2
\--> {"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]
{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}

包含无效请求对象的rpc调用:

1
2
\--> {"jsonrpc": "2.0", "method": 1, "params": "bar"}
{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}

包含无效json的rpc批量调用:

1
2
3
4
5
\--> [
{"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
{"jsonrpc": "2.0", "method"
]
{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}

包含空数组的rpc调用:

1
2
\--> []
{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}

非空且无效的rpc批量调用:

1
2
3
4
\--> [1]
[
{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}
]

无效的rpc批量调用:

1
2
3
4
5
6
\--> [1,2,3]
[
{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}
]

rpc批量调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
\--> [
{"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
{"jsonrpc": "2.0", "method": "notify_hello", "params": [7]},
{"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"},
{"foo": "boo"},
{"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"},
{"jsonrpc": "2.0", "method": "get_data", "id": "9"}
]
[
{"jsonrpc": "2.0", "result": 7, "id": "1"},
{"jsonrpc": "2.0", "result": 19, "id": "2"},
{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "5"},
{"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}
]

所有都为通知的rpc批量调用:

1
2
3
4
5
6
\--> [
{"jsonrpc": "2.0", "method": "notify_sum", "params": [1,2,4]},
{"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}
]

//Nothing is returned for all notification batches

8. 扩展

以 rpc 开头的方法名预留作为系统扩展,且必须不能用于其他地方。每个系统扩展都应该有相关规范文档,所有系统扩展都应是可选的。

技术杂谈

在 Python 中,一般情况下我们可能直接用自带的 logging 模块来记录日志,包括我之前的时候也是一样。在使用时我们需要配置一些 Handler、Formatter 来进行一些处理,比如把日志输出到不同的位置,或者设置一个不同的输出格式,或者设置日志分块和备份。但其实个人感觉 logging 用起来其实并不是那么好用,其实主要还是配置较为繁琐。

常见使用

首先看看 logging 常见的解决方案吧,我一般会配置输出到文件、控制台和 Elasticsearch。输出到控制台就仅仅是方便直接查看的;输出到文件是方便直接存储,保留所有历史记录的备份;输出到 Elasticsearch,直接将 Elasticsearch 作为存储和分析的中心,使用 Kibana 可以非常方便地分析和查看运行情况。 所以在这里我基本会对 logging 做如下的封装写法:

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
import logging
import sys
from os import makedirs
from os.path import dirname, exists

from cmreslogging.handlers import CMRESHandler

loggers = {}

LOG_ENABLED = True # 是否开启日志
LOG_TO_CONSOLE = True # 是否输出到控制台
LOG_TO_FILE = True # 是否输出到文件
LOG_TO_ES = True # 是否输出到 Elasticsearch

LOG_PATH = './runtime.log' # 日志文件路径
LOG_LEVEL = 'DEBUG' # 日志级别
LOG_FORMAT = '%(levelname)s - %(asctime)s - process: %(process)d - %(filename)s - %(name)s - %(lineno)d - %(module)s - %(message)s' # 每条日志输出格式
ELASTIC_SEARCH_HOST = 'eshost' # Elasticsearch Host
ELASTIC_SEARCH_PORT = 9200 # Elasticsearch Port
ELASTIC_SEARCH_INDEX = 'runtime' # Elasticsearch Index Name
APP_ENVIRONMENT = 'dev' # 运行环境,如测试环境还是生产环境

def get_logger(name=None):
"""
get logger by name
:param name: name of logger
:return: logger
"""
global loggers

if not name: name = __name__

if loggers.get(name):
return loggers.get(name)

logger = logging.getLogger(name)
logger.setLevel(LOG_LEVEL)

# 输出到控制台
if LOG_ENABLED and LOG_TO_CONSOLE:
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setLevel(level=LOG_LEVEL)
formatter = logging.Formatter(LOG_FORMAT)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)

# 输出到文件
if LOG_ENABLED and LOG_TO_FILE:
# 如果路径不存在,创建日志文件文件夹
log_dir = dirname(log_path)
if not exists(log_dir): makedirs(log_dir)
# 添加 FileHandler
file_handler = logging.FileHandler(log_path, encoding='utf-8')
file_handler.setLevel(level=LOG_LEVEL)
formatter = logging.Formatter(LOG_FORMAT)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)

# 输出到 Elasticsearch
if LOG_ENABLED and LOG_TO_ES:
# 添加 CMRESHandler
es_handler = CMRESHandler(hosts=[{'host': ELASTIC_SEARCH_HOST, 'port': ELASTIC_SEARCH_PORT}],
# 可以配置对应的认证权限
auth_type=CMRESHandler.AuthType.NO_AUTH,
es_index_name=ELASTIC_SEARCH_INDEX,
# 一个月分一个 Index
index_name_frequency=CMRESHandler.IndexNameFrequency.MONTHLY,
# 额外增加环境标识
es_additional_fields={'environment': APP_ENVIRONMENT}
)
es_handler.setLevel(level=LOG_LEVEL)
formatter = logging.Formatter(LOG_FORMAT)
es_handler.setFormatter(formatter)
logger.addHandler(es_handler)

# 保存到全局 loggers
loggers[name] = logger
return logger

定义完了怎么使用呢?只需要使用定义的方法获取一个 logger,然后 log 对应的内容即可:

1
2
logger = get_logger()
logger.debug('this is a message')

运行结果如下:

1
DEBUG - 2019-10-11 22:27:35,923 - process: 99490 - logger.py - __main__ - 81 - logger - this is a message

我们看看这个定义的基本实现吧。首先这里一些常量是用来定义 logging 模块的一些基本属性的,比如 LOG_ENABLED 代表是否开启日志功能,LOG_TO_ES 代表是否将日志输出到 Elasticsearch,另外还有很多其他的日志基本配置,如 LOG_FORMAT 配置了日志每个条目输出的基本格式,另外还有一些连接的必要信息。这些变量可以和运行时的命令行或环境变量对接起来,可以方便地实现一些开关和配置的更换。 然后定义了这么一个 get_logger 方法,接收一个参数 name。首先该方法拿到 name 之后,会到全局的 loggers 变量里面查找,loggers 变量是一个全局字典,如果有已经声明过的 logger,直接将其获取返回即可,不用再将其二次初始化。如果 loggers 里面没有找到 name 对应的 logger,那就进行创建即可。创建 logger 之后,可以为其添加各种对应的 Handler,如输出到控制台就用 StreamHandler,输出到文件就用 FileHandler 或 RotatingFileHandler,输出到 Elasticsearch 就用 CMRESHandler,分别配置好对应的信息即可。 最后呢,将新建的 logger 保存到全局的 loggers 里面并返回即可,这样如果有同名的 logger 便可以直接查找 loggers 直接返回了。 在这里依赖了额外的输出到 Elasticsearch 的包,叫做 CMRESHandler,它可以支持将日志输出到 Elasticsearch 里面,如果要使用的话可以安装一下:

1
pip install CMRESHandler

其 GitHub 地址是:https://github.com/cmanaha/python-elasticsearch-logger,具体的使用方式可以看看它的官方说明,如配置认证信息,配置 Index 分隔信息等等。 好,上面就是我之前常用的 logging 配置,通过如上的配置,我就可以实现将 logging 输出到三个位置,并可以实现对应的效果。比如输出到 Elasticsearch 之后,我就可以非常方便地使用 Kibana 来查看当前运行情况,ERROR Log 的比例等等,如图所示: 也可以在它的基础上做更进一步的统计分析。

loguru

上面的实现方式已经是一个较为可行的配置方案了。然而,我还是会感觉到有些 Handler 配起来麻烦,尤其是新建一个项目的很多时候懒得去写一些配置。即使是不用上文的配置,用最基本的几行 logging 配置,像如下的通用配置:

1
2
3
import logging
logging.basicConfig(level = logging.INFO,format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

我也懒得去写,感觉并不是一个优雅的实现方式。 有需求就有动力啊,这不,就有人实现了这么一个库,叫做 loguru,可以将 log 的配置和使用更加简单和方便。 下面我们来看看它到底是怎么用的吧。

安装

首先,这个库的安装方式很简单,就用基本的 pip 安装即可,Python 3 版本的安装如下:

1
pip3 install loguru

安装完毕之后,我们就可以在项目里使用这个 loguru 库了。

基本使用

那么这个库怎么来用呢?我们先用一个实例感受下:

1
2
3
from loguru import logger

logger.debug('this is a debug message')

看到了吧,不需要配置什么东西,直接引入一个 logger,然后调用其 debug 方法即可。 在 loguru 里面有且仅有一个主要对象,那就是 logger,loguru 里面有且仅有一个 logger,而且它已经被提前配置了一些基础信息,比如比较友好的格式化、文本颜色信息等等。 上面的代码运行结果如下:

1
2019-10-13 22:46:12.367 | DEBUG    | __main__:<module>:4 - this is a debug message

可以看到其默认的输出格式是上面的内容,有时间、级别、模块名、行号以及日志信息,不需要手动创建 logger,直接使用即可,另外其输出还是彩色的,看起来会更加友好。 以上的日志信息是直接输出到控制台的,并没有输出到其他的地方,如果想要输出到其他的位置,比如存为文件,我们只需要使用一行代码声明即可。 例如将结果同时输出到一个 runtime.log 文件里面,可以这么写:

1
2
3
4
from loguru import logger

logger.add('runtime.log')
logger.debug('this is a debug')

很简单吧,我们也不需要再声明一个 FileHandler 了,就一行 add 语句搞定,运行之后会发现目录下 runtime.log 里面同样出现了刚刚控制台输出的 DEBUG 信息。 上面就是一些基本的使用,但这还远远不够,下面我们来详细了解下它的一些功能模块。

详细使用

既然是日志,那么最常见的就是输出到文件了。loguru 对输出到文件的配置有非常强大的支持,比如支持输出到多个文件,分级别分别输出,过大创建新文件,过久自动删除等等。 下面我们分别看看这些怎样来实现,这里基本上就是 add 方法的使用介绍。因为这个 add 方法就相当于给 logger 添加了一个 Handler,它给我们暴露了许多参数来实现 Handler 的配置,下面我们来详细介绍下。 首先看看它的方法定义吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def add(
self,
sink,
*,
level=_defaults.LOGURU_LEVEL,
format=_defaults.LOGURU_FORMAT,
filter=_defaults.LOGURU_FILTER,
colorize=_defaults.LOGURU_COLORIZE,
serialize=_defaults.LOGURU_SERIALIZE,
backtrace=_defaults.LOGURU_BACKTRACE,
diagnose=_defaults.LOGURU_DIAGNOSE,
enqueue=_defaults.LOGURU_ENQUEUE,
catch=_defaults.LOGURU_CATCH,
**kwargs
):
pass

看看它的源代码,它支持这么多的参数,如 level、format、filter、color 等等,另外我们还注意到它有个非常重要的参数 sink,我们看看官方文档:https://loguru.readthedocs.io/en/stable/api/logger.html#sink,可以了解到通过 sink 我们可以传入多种不同的数据结构,汇总如下:

  • sink 可以传入一个 file 对象,例如 sys.stderr 或者 open('file.log', 'w') 都可以。
  • sink 可以直接传入一个 str 字符串或者 pathlib.Path 对象,其实就是代表文件路径的,如果识别到是这种类型,它会自动创建对应路径的日志文件并将日志输出进去。
  • sink 可以是一个方法,可以自行定义输出实现。
  • sink 可以是一个 logging 模块的 Handler,比如 FileHandler、StreamHandler 等等,或者上文中我们提到的 CMRESHandler 照样也是可以的,这样就可以实现自定义 Handler 的配置。
  • sink 还可以是一个自定义的类,具体的实现规范可以参见官方文档。

所以说,刚才我们所演示的输出到文件,仅仅给它传了一个 str 字符串路径,他就给我们创建了一个日志文件,就是这个原理。

基本参数

下面我们再了解下它的其他参数,例如 format、filter、level 等等。 其实它们的概念和格式和 logging 模块都是基本一样的了,例如这里使用 format、filter、level 来规定输出的格式:

1
logger.add('runtime.log', format="{time} {level} {message}", filter="my_module", level="INFO")

删除 sink

另外添加 sink 之后我们也可以对其进行删除,相当于重新刷新并写入新的内容。 删除的时候根据刚刚 add 方法返回的 id 进行删除即可,看下面的例子:

1
2
3
4
5
6
from loguru import logger

trace = logger.add('runtime.log')
logger.debug('this is a debug message')
logger.remove(trace)
logger.debug('this is another debug message')

看这里,我们首先 add 了一个 sink,然后获取它的返回值,赋值为 trace。随后输出了一条日志,然后将 trace 变量传给 remove 方法,再次输出一条日志,看看结果是怎样的。 控制台输出如下:

1
2
2019-10-13 23:18:26.469 | DEBUG    | __main__:<module>:4 - this is a debug message
2019-10-13 23:18:26.469 | DEBUG | __main__:<module>:6 - this is another debug message

日志文件 runtime.log 内容如下:

1
2019-10-13 23:18:26.469 | DEBUG    | __main__:<module>:4 - this is a debug message

可以发现,在调用 remove 方法之后,确实将历史 log 删除了。但实际上这并不是删除,只不过是将 sink 对象移除之后,在这之前的内容不会再输出到日志中。 这样我们就可以实现日志的刷新重新写入操作。

rotation 配置

用了 loguru 我们还可以非常方便地使用 rotation 配置,比如我们想一天输出一个日志文件,或者文件太大了自动分隔日志文件,我们可以直接使用 add 方法的 rotation 参数进行配置。 我们看看下面的例子:

1
logger.add('runtime_{time}.log', rotation="500 MB")

通过这样的配置我们就可以实现每 500MB 存储一个文件,每个 log 文件过大就会新创建一个 log 文件。我们在配置 log 名字时加上了一个 time 占位符,这样在生成时可以自动将时间替换进去,生成一个文件名包含时间的 log 文件。 另外我们也可以使用 rotation 参数实现定时创建 log 文件,例如:

1
logger.add('runtime_{time}.log', rotation='00:00')

这样就可以实现每天 0 点新创建一个 log 文件输出了。 另外我们也可以配置 log 文件的循环时间,比如每隔一周创建一个 log 文件,写法如下:

1
logger.add('runtime_{time}.log', rotation='1 week')

这样我们就可以实现一周创建一个 log 文件了。

retention 配置

很多情况下,一些非常久远的 log 对我们来说并没有什么用处了,它白白占据了一些存储空间,不清除掉就会非常浪费。retention 这个参数可以配置日志的最长保留时间。 比如我们想要设置日志文件最长保留 10 天,可以这么来配置:

1
logger.add('runtime.log', retention='10 days')

这样 log 文件里面就会保留最新 10 天的 log,妈妈再也不用担心 log 沉积的问题啦。

compression 配置

loguru 还可以配置文件的压缩格式,比如使用 zip 文件格式保存,示例如下:

1
logger.add('runtime.log', compression='zip')

这样可以更加节省存储空间。

字符串格式化

loguru 在输出 log 的时候还提供了非常友好的字符串格式化功能,像这样:

1
logger.info('If you are using Python {}, prefer {feature} of course!', 3.6, feature='f-strings')

这样在添加参数就非常方便了。

Traceback 记录

在很多情况下,如果遇到运行错误,而我们在打印输出 log 的时候万一不小心没有配置好 Traceback 的输出,很有可能我们就没法追踪错误所在了。 但用了 loguru 之后,我们用它提供的装饰器就可以直接进行 Traceback 的记录,类似这样的配置即可:

1
2
3
4
@logger.catch
def my_function(x, y, z):
# An error? It's caught anyway!
return 1 / (x + y + z)

我们做个测试,我们在调用时三个参数都传入 0,直接引发除以 0 的错误,看看会出现什么情况:

1
my_function(0, 0, 0)

运行完毕之后,可以发现 log 里面就出现了 Traceback 信息,而且给我们输出了当时的变量值,真的是不能再赞了!结果如下:

1
2
3
4
5
6
7
8
9
10
11
\> File "run.py", line 15, in <module>
my_function(0, 0, 0)
<function my_function at 0x1171dd510>

File "/private/var/py/logurutest/demo5.py", line 13, in my_function
return 1 / (x + y + z)
0
0
0

ZeroDivisionError: division by zero

因此,用 loguru 可以非常方便地实现日志追踪,debug 效率可能要高上十倍了? 另外 loguru 还有很多很多强大的功能,这里就不再一一展开讲解了,更多的内容大家可以看看 loguru 的官方文档详细了解一下:https://loguru.readthedocs.io/en/stable/index.html。 看完之后,是时候把自己的 logging 模块替换成 loguru 啦!

JavaScript

在夜幕读者群和算法反爬虫群的朋友都知道,我的新书《Python3 反爬虫原理与绕过实战》很快就要印刷出版了。 出版社的小姐姐们为本书设计了很多款封面

但目前暂未选定封面

之前我也有放出大章目录和配套代码,但详细目录和最新进展一直没机会公开。配套代码放在 GitHub 仓库,大章目录也在。 这次将详细目录呈现给大家。请大家先阅读《Python3 反爬虫原理与绕过实战》的内容提要

本书描述了爬虫技术与反爬虫技术的对抗过程,并详细介绍了这其中的原理和具体实现方法。首先讲 解开发环境的配置、Web 网站的构成、页面渲染以及动态网页和静态网页对爬虫造成的影响。然后介绍了 不同类型的反爬虫原理、具体实现和绕过方法,另外还涉及常见验证码的实现过程,并使用深度学习技术 完成了验证。最后介绍了常见的编码和加密原理、JavaScript 代码混淆知识、前端禁止事件以及与爬虫相 关的法律知识和风险点。 本书既适合需要储备反爬虫知识的前端工程师和后端工程师,也适合需要储备绕过知识的爬虫工程师、 爬虫爱好者以及 Python 程序员。

作者是谁

这本书谁写的?靠不靠谱呢? 这个靓仔就是我,韦世东。 作者韦世东是资深爬虫工程师,2019年华为云认证云享专家、掘金社区优秀作者、GitChat认证作者、搜狐产品技术约稿作者、夜幕团队成员。拥有七年互联网从业经验,擅长反爬虫的设计和绕过技巧。

详细目录

以下放出的章节目录为改版前的目录,大部分章和节都配套实战环节,实际上新版目录与这里的略有差异。

什么时候可以买到?

审核、校对和排版工作早已进行,按照正常流程来说月底送印,双十一之前会在各大在线书城(如京东、当当等)跟大家见面。 同时也会开启直播送书、抽奖送书等活动。想要参与活动的朋友可以添加我好友,微信号:Domfreez。加好友进群以获得书籍和活动的最新消息。欢迎大家保持对《Python3 反爬虫原理与绕过实战》的关注,新书发布后会有很多活动回馈给大家!

技术杂谈

大家有没有一种感觉,很多网站其实做得非常优秀,但是它们就是没有开发 PC (电脑)版的客户端,比如知乎、GitHub、微信公众号。 如果我们大多数时间都是使用 PC 开发或者办公的,每次开始时我们都需要打开浏览器输入它们的网址,进入对应的页面。另外一个浏览器中我们可能会开各种各样的选项卡,少则两三个,多则一二十个,这就导致某些我们常用的甚至重度依赖的网站在切换的时候就会不怎么方便。 比如挤在一堆浏览器里面的 GitHub,选项卡已经被挤得看不全了: image-20191009212626789 这时候,如果我们能有一个客户端,即 Window 上的 exe 程序或 Mac 上的 app 应用程序,它们的名字就叫做 GitHub、微信公众平台等等,打开之后只单独负责呈现 GitHub、微信公众号的内容,我们就可以免去在浏览器中来回寻找站点和切换站点的麻烦。 甚至说,在 Windows 上我们可以直接把这个应用放在桌面或把它 Pin 到任务栏上, Mac 上我们可以直接将它固定到 Dock 栏上,这样一键就打开了,省时省力。如果使用了快捷启动软件,比如 Wox (Windows)或 Alfred(Mac),直接输入 GitHub 或者微信公众平台,那就更方便唤出了,简直不要太方便。 而且,我个人感觉,用客户端软件比用网页更有一种「踏实感」,不知道大家会不会也有这种感觉。 所以,如果能将这些常用的或者重度依赖的网站转成客户端软件,那就再方便不过了。 比如我用的是 Mac,把 GitHub 转成客户端软件之后,我习惯性用 Alfred 呼出: image-20191009213959316 然后就打开了一个 GitHub.app: image-20191009214125953 然后把它固定到 Dock 栏上: image-20191009214254670 就仿佛拥有了一个 GitHub 的客户端,功能与网页一模一样,再也不用在浏览器里面切来切去。而且也不用担心版本更新的问题,因为它就是开了一个独立的网页,网页改版或者更新,内容就随着更新。 是不是很方便呢? 如果你觉得是,那就随着我来了解一下怎样实现吧。

nativefier

这里需要用到的一个工具,名字叫做 nativefier,是基于 electron 开发的,它的功能就是把任意的网页转成一个电脑客户端,即 Desktop Application, 有了这个软件,把网页转成电脑客户端只需要这么一条简单的命令:

1
nativefier <website>

比如把 Whatsapp 的网站打包成一个客户端就只需要执行这样的命令:

1
nativefier web.whatsapp.com

示意如下: Walkthrough 怎样,不论是什么网页,就可以使用它来转换成一个客户端软件。 另外它支持三大操作系统,Windows、Linux、Mac,即用它可以将网页转成 .exe.app 等格式。

安装

那么这软件究竟具体怎么来使用呢,第一步当然就是安装了。 由于 nativefier 是基于 electron 开发的,而后者又是基于 Node.js 的,所以要使用它必须要安装 Node.js,建议安装 6.0 以上版本。 另外在 Linux 和 Mac 平台可能需要安装其他的依赖。

  • 在 Linux 上需要安装 Wine 并配置好环境变量。
  • 在 Mac 上需要安装 iconutil、imagemagick,这两个依赖是为了帮助程序处理 App 的 icon 的。

具体的安装说明可以参见:https://github.com/jiahaog/nativefier#optional-dependencies。 以上步骤完成之后,使用 npm 安装 nativefier 即可:

1
npm install nativefier -g

安装完毕之后便可以使用 nativefier 命令了。

使用

下面我在 Mac 下以 GitHub 为例来介绍下怎样将 GitHub 打包成一个客户端软件。 像刚才介绍的一样,最简单直接的,运行下面的命令就好了:

1
nativefier https://github.com

它会尝试用 GitHub 主页的 title 来命名这个客户端,而 GitHub 的 title 比较长,叫做:

1
The worlds leading software development platform  GitHub

所以它会生成这样的一个客户端软件: image-20191009220450996 这个名字有点奇怪,我们可以使用命令的一个选项即可控制生成的客户端的名称,添加一个 name 参数即可:

1
nativefier --name GitHub https://github.com

这样便会生成一个名为 GitHub 的客户端: image-20191009220717549 另外我们可以看到客户端的图标也自动生成了,这个图标怎么来的呢?这个是用的 nativefier 维护的 icons,恰好 GitHub 在它们的收录范围内,所以就用上了。这些 icons 也是一个公开的 Repository,链接为: https://github.com/jiahaog/nativefier-icons,大家可以到这里搜集或者贡献图标。 如果我们觉得 nativefier 官方提供的图标不好看,想要自定义图标的话,也是可以的,只需要添加一个 icon 参数即可,这样便可以指定本地图片作为图标来生成了。 但值得注意的是,不同平台上要求的图标格式不一样。

  • Windows 上需要 ico 格式。
  • Linux 上需要 png 格式。
  • Mac 上需要 icns 格式,如果安装了上文所需要的依赖,使用 png 格式也是可以的。

具体的参数用法说明可以看:https://github.com/jiahaog/nativefier/blob/master/docs/api.md#icon。 好,那么在 Mac 上我安装了依赖,那就直接用 png 格式的图标了。 在这里我自己做了一个圆形的图标如下,命名为 github.png: 2019-10-09-141852 然后把图片使用下面的命令就可以自定义图标了:

1
nativefier --name GitHub --icon ./github.png https://github.com

这样就能生成自定义图标的客户端软件了。 打开之后,登录,我们就拥有了一个 GitHub 客户端了,界面和网页一模一样,但是已经摆脱了混杂选项卡的干扰,示意如下: image-20191009223006991 好了,这就是基本的用法,其实大部分情况只需要这几个参数就够了,如果想了解功能大家可以参考官方的 API 文档:https://github.com/jiahaog/nativefier/blob/master/docs/api.md#api。 如果想要生成其他的客户端,如微信公众平台、知乎等等都是可以的。 如微信公众平台就是这样的: image-20191009222257275

注意

在使用过程中我发现 name 参数对中文的支持并不好,总会生成一个 APP 的客户端,在这里推荐 name 使用英文名称,比如知乎用 Zhihu,微信平台用 WXMP 等等。 例如命令:

1
nativefier --name 知乎 --icon ./zhihu.png https://www.zhihu.com

可以用下面的命令代替:

1
nativefier --name Zhihu --icon ./zhihu.png https://www.zhihu.com

生成客户端软件知乎再手动修改下图标的名称即可。 另外生成的客户端软件是不支持插件的,如果你的站点对某些插件的依赖比较强,那就不建议使用 nativefier 转成的客户端了。 好了,这就是 nativefier 的基本用法,有了它我们就可以随意地将网页转成客户端软件了,快来试试吧!

技术杂谈

趁着周末,搭建了一下 NightTeam 的官方博客和官方主页,耗时数个小时,两个站点终于完工了。 由于 NightTeam 的域名是 nightteam.cn,所以这里官方博客使用了二级域名 blog.nightteam.cn,官方主页使用了根域名 nightteam.cn,现在两个站点都已经稳定运行在 GitHub Pages 上面了,大家如果感兴趣可以去看一下。

这里的主页就是用一个基本的静态页面搭建了,没有什么技术含量。博客相对复杂一点,使用了 Hexo 框架,采用了 Next 主题,在搭建的过程中我就顺手把搭建的流程大致记录下来了,在这里扩充一下形成一篇记录,毕竟好记性不如烂笔头。 于是,这篇《利用 GitHub 从零开始搭建一个博客》的文章就诞生了。

准备条件

在这里先跟大家说一些准备条件,有些同学可能一听到搭建博客就望而却步。弄个博客网站,不得有台服务器吗?不得搞数据库吗?不得注册域名吗?没事,如果都没有,那照样是能搭建一个博客的。 GitHub 是个好东西啊,它提供了 GitHub Pages 帮助我们来架设一个静态网站,这就解决了服务器的问题。 Hexo 这个博客框架没有那么重量级,它是 MarkDown 直接写文章的,然后 Hexo 可以直接将文章编译成静态网页文件并发布,所以这样文章的内容、标题、标签等信息就没必要存数据库里面了,是直接纯静态页面了,这就解决了数据库的问题。 GitHub Pages 允许每个账户创建一个名为 {username}.github.io 的仓库,另外它还会自动为这个仓库分配一个 github.io 的二级域名,这就解决了域名的问题,当然如果想要自定义域名的话,也可以支持。 所以说,基本上,先注册个 GitHub 账号就能搞了,下面我们来正式开始吧。

新建项目

首先在 GitHub 新建一个仓库(Repository),名称为 {username}.github.io,注意这个名比较特殊,必须要是 github.io 为后缀结尾的。比如 NightTeam 的 GitHub 用户名就叫 NightTeam,那我就新建一个 nightteam.github.io,新建完成之后就可以进行后续操作了。 另外如果 GitHub 没有配置 SSH 连接的建议配置一下,这样后面在部署博客的时候会更方便。

安装环境

安装 Node.js

首先在自己的电脑上安装 Node.js,下载地址:https://nodejs.org/zh-cn/download/,可以安装 Stable 版本。 安装完毕之后,确保环境变量配置好,能正常使用 npm 命令。

安装 Hexo

接下来就需要安装 Hexo 了,这是一个博客框架,Hexo 官方还提供了一个命令行工具,用于快速创建项目、页面、编译、部署 Hexo 博客,所以在这之前我们需要先安装 Hexo 的命令行工具。 命令如下:

1
npm install -g hexo-cli

安装完毕之后,确保环境变量配置好,能正常使用 hexo 命令。

初始化项目

接下来我们使用 Hexo 的命令行创建一个项目,并将其在本地跑起来,整体跑通看看。 首先使用如下命令创建项目:

1
hexo init {name}

这里的 name 就是项目名,我这里要创建 NightTeam 的博客,我就把项目取名为 nightteam 了,用了纯小写,命令如下:

1
hexo init nightteam

这样 nightteam 文件夹下就会出现 Hexo 的初始化文件,包括 themes、scaffolds、source 等文件夹,这些内容暂且先不用管是做什么的,我们先知道有什么,然后一步步走下去看看都发生了什么变化。 接下来我们首先进入新生成的文件夹里面,然后调用 Hexo 的 generate 命令,将 Hexo 编译生成 HTML 代码,命令如下:

1
hexo generate

可以看到输出结果里面包含了 js、css、font 等内容,并发现他们都处在了项目根目录下的 public 文件夹下面了。 然后我们利用 Hexo 提供的 server 命令把博客在本地运行起来,命令如下:

1
hexo server

运行之后命令行输出如下:

1
2
INFO  Start processing
INFO Hexo is running at http://localhost:4000 . Press Ctrl+C to stop.

它告诉我们在本地 4000 端口上就可以查看博客站点了,如图所示: 这样一个博客的架子就出来了,我们只用了三个命令就完成了。

部署

接下来我们来将这个初始化的博客进行一下部署,放到 GitHub Pages 上面验证一下其可用性。成功之后我们可以再进行后续的修改,比如修改主题、修改页面配置等等。 那么怎么把这个页面部署到 GitHub Pages 上面呢,其实 Hexo 已经给我们提供一个命令,利用它我们可以直接将博客一键部署,不需要手动去配置服务器或进行其他的各项配置。 部署命令如下:

1
hexo deploy

在部署之前,我们需要先知道博客的部署地址,它需要对应 GitHub 的一个 Repository 的地址,这个信息需要我们来配置一下。 打开根目录下的 _config.yml 文件,找到 Deployment 这个地方,把刚才新建的 Repository 的地址贴过来,然后指定分支为 master 分支,最终修改为如下内容:

1
2
3
4
5
6
# Deployment
## Docs: https://hexo.io/docs/deployment.html
deploy:
type: git
repo: {git repo ssh address}
branch: master

我的就修改为如下内容:

1
2
3
4
5
6
# Deployment
## Docs: https://hexo.io/docs/deployment.html
deploy:
type: git
repo: git@github.com:NightTeam/nightteam.github.io.git
branch: master

另外我们还需要额外安装一个支持 Git 的部署插件,名字叫做 hexo-deployer-git,有了它我们才可以顺利将其部署到 GitHub 上面,如果不安装的话,在执行部署命令时会报如下错误:

1
Deployer not found: git

好,那就让我们安装下这个插件,在项目目录下执行安装命令如下:

1
npm install hexo-deployer-git --save

安装成功之后,执行部署命令:

1
hexo deploy

运行结果类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
INFO  Deploying: git
INFO Clearing .deploy_git folder...
INFO Copying files from public folder...
INFO Copying files from extend dirs...
On branch master
nothing to commit, working directory clean
Counting objects: 46, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (36/36), done.
Writing objects: 100% (46/46), 507.66 KiB | 0 bytes/s, done.
Total 46 (delta 3), reused 0 (delta 0)
remote: Resolving deltas: 100% (3/3), done.
To git@github.com:NightTeam/nightteam.github.io.git
* [new branch] HEAD -> master
Branch master set up to track remote branch master from git@github.com:NightTeam/nightteam.github.io.git.
INFO Deploy done: git

如果出现类似上面的内容,就证明我们的博客已经成功部署到 GitHub Pages 上面了,这时候我们访问一下 GitHub Repository 同名的链接,比如我的 NightTeam 博客的 Repository 名称取的是 nightteam.github.io,那我就访问 http://nightteam.github.io,这时候我们就可以看到跟本地一模一样的博客内容了。 这时候我们去 GitHub 上面看看 Hexo 上传了什么内容,打开之后可以看到 master 分支有了这样的内容: 仔细看看,这实际上是博客文件夹下面的 public 文件夹下的所有内容,Hexo 把编译之后的静态页面内容上传到 GitHub 的 master 分支上面去了。 这时候可能就有人有疑问了,那我博客的源码也想放到 GitHub 上面怎么办呢?其实很简单,新建一个其他的分支就好了,比如我这边就新建了一个 source 分支,代表博客源码的意思。 具体的添加过程就很简单了,参加如下命令:

1
2
3
4
5
6
git init
git checkout -b source
git add -A
git commit -m "init blog"
git remote add origin git@github.com:{username}/{username}.github.io.git
git push origin source

成功之后,可以到 GitHub 上再切换下默认分支,比如我就把默认的分支设置为了 source,当然不换也可以。

配置站点信息

完成如上内容之后,实际上我们只完成了博客搭建的一小步,因为我们仅仅是把初始化的页面部署成功了,博客里面还没有设置任何有效的信息。下面就让我们来进行一下博客的基本配置,另外换一个好看的主题,配置一些其他的内容,让博客真正变成属于我们自己的博客吧。 下面我就以自己的站点 NightTeam 为例,修改一些基本的配置,比如站点名、站点描述等等。 修改根目录下的 _config.yml 文件,找到 Site 区域,这里面可以配置站点标题 title、副标题 subtitle 等内容、关键字 keywords 等内容,比如我的就修改为如下内容:

1
2
3
4
5
6
# Site
title: NightTeam
subtitle: 一个专注技术的组织
description: 涉猎的主要编程语言为 Python、Rust、C++、Go,领域涵盖爬虫、深度学习、服务研发和对象存储等。
keywords: "Python, Rust, C++, Go, 爬虫, 深度学习, 服务研发, 对象存储"
author: NightTeam

这里大家可以参照格式把内容改成自己的。 另外还可以设置一下语言,如果要设置为汉语的话可以将 language 的字段设置为 zh-CN,修改如下:

1
language: zh-CN

这样就完成了站点基本信息的配置,完成之后可以看到一些基本信息就修改过来了,页面效果如下:

修改主题

目前来看,整个页面的样式个人感觉并不是那么好看,想换一个风格,这就涉及到主题的配置了。目前 Hexo 里面应用最多的主题基本就是 Next 主题了,个人感觉这个主题还是挺好看的,另外它支持的插件和功能也极为丰富,配置了这个主题,我们的博客可以支持更多的扩展功能,比如阅览进度条、中英文空格排版、图片懒加载等等。 那么首先就让我们来安装下 Next 这个主题吧,目前 Next 主题已经更新到 7.x 版本了,我们可以直接到 Next 主题的 GitHub Repository 上把这个主题下载下来。 主题的 GitHub 地址是:https://github.com/theme-next/hexo-theme-next,我们可以直接把 master 分支 Clone 下来。 首先命令行进入到项目的根目录,执行如下命令即可:

1
git clone https://github.com/theme-next/hexo-theme-next themes/next

执行完毕之后 Next 主题的源码就会出现在项目的 themes/next 文件夹下。 然后我们需要修改下博客所用的主题名称,修改项目根目录下的 _config.yml 文件,找到 theme 字段,修改为 next 即可,修改如下:

1
theme: next

然后本地重新开启服务,访问刷新下页面,就可以看到 next 主题就切换成功了,预览效果如下:

主题配置

现在我们已经成功切换到 next 主题上面了,接下来我们就对主题进行进一步地详细配置吧,比如修改样式、增加其他各项功能的支持,下面逐项道来。 Next 主题内部也提供了一个配置文件,名字同样叫做 _config.yml,只不过位置不一样,它在 themes/next 文件夹下,Next 主题里面所有的功能都可以通过这个配置文件来控制,下文所述的内容都是修改的 themes/next/_config.yml 文件。

样式

Next 主题还提供了多种样式,风格都是类似黑白的搭配,但整个布局位置不太一样,通过修改配置文件的 scheme 字段即可,我选了 Pisces 样式,修改 _config.yml (注意是 themes/next/_config.yml 文件)如下:

1
scheme: Pisces

刷新页面之后就会变成这种样式,如图所示: 另外还有几个可选项,比如:

1
2
3
4
# scheme: Muse
#scheme: Mist
scheme: Pisces
#scheme: Gemini

大家可以自行根据喜好选择。

favicon

favicon 就是站点标签栏的小图标,默认是用的 Hexo 的小图标,如果我们有站点 Logo 的图片的话,我们可以自己定制小图标。 但这并不意味着我们需要自己用 PS 自己来设计,已经有一个网站可以直接将图片转化为站点小图标,站点链接为:https://realfavicongenerator.net,到这里上传一张图,便可以直接打包下载各种尺寸和适配不同设备的小图标。 图标下载下来之后把它放在 themes/next/source/images 目录下面。 然后在配置文件里面找到 favicon 配置项,把一些相关路径配置进去即可,示例如下:

1
2
3
4
5
favicon:
small: /images/favicon-16x16.png
medium: /images/favicon-32x32.png
apple_touch_icon: /images/apple-touch-icon.png
safari_pinned_tab: /images/safari-pinned-tab.svg

配置完成之后刷新页面,整个页面的标签图标就被更新了。

avatar

avatar 这个就类似站点的头像,如果设置了这个,会在站点的作者信息旁边额外显示一个头像,比如我这边有一张 avatar.png 图片: 将其放置到 themes/next/source/images/avatar.png 路径,然后在主题 _config.yml 文件下编辑 avatar 的配置,修改为正确的路径即可。

1
2
3
4
5
6
7
8
9
10
# Sidebar Avatar
avatar:
# In theme directory (source/images): /images/avatar.gif
# In site directory (source/uploads): /uploads/avatar.gif
# You can also use other linking images.
url: /images/avatar.png
# If true, the avatar would be dispalyed in circle.
rounded: true
# If true, the avatar would be rotated with the cursor.
rotated: true

这里有 rounded 选项是是否显示圆形,rotated 是是否带有旋转效果,大家可以根据喜好选择是否开启。 效果如下: 配置完成之后就会显示头像。

rss

博客一般是需要 RSS 订阅的,如果要开启 RSS 订阅,这里需要安装一个插件,叫做 hexo-generator-feed,安装完成之后,站点会自动生成 RSS Feed 文件,安装命令如下:

1
npm install hexo-generator-feed --save

在项目根目录下运行这个命令,安装完成之后不需要其他的配置,以后每次编译生成站点的时候就会自动生成 RSS Feed 文件了。

code

作为程序猿,代码块的显示还是需要很讲究的,默认的代码块我个人不是特别喜欢,因此我把代码的颜色修改为黑色,并把复制按钮的样式修改为类似 Mac 的样式,修改 _config.yml 文件的 codeblock 区块如下:

1
2
3
4
5
6
7
8
9
10
11
12
codeblock:
# Code Highlight theme
# Available values: normal | night | night eighties | night blue | night bright
# See: https://github.com/chriskempson/tomorrow-theme
highlight_theme: night bright
# Add copy button on codeblock
copy_button:
enable: true
# Show text copy result.
show_result: true
# Available values: default | flat | mac
style: mac

修改前的代码样式: 修改后的代码样式: 嗯,个人觉得整体看起来逼格高了不少。

top

我们在浏览网页的时候,如果已经看完了想快速返回到网站的上端,一般都是有一个按钮来辅助的,这里也支持它的配置,修改 _config.yml 的 back2top 字段即可,我的设置如下:

1
2
3
4
5
6
back2top:
enable: true
# Back to top in sidebar.
sidebar: false
# Scroll percent label in b2t button.
scrollpercent: true

enable 默认为 true,即默认显示。sidebar 如果设置为 true,按钮会出现在侧栏下方,个人觉得并不是很好看,就取消了,scrollpercent 就是显示阅读百分比,个人觉得还不错,就将其设置为 true。 具体的效果大家可以设置后根据喜好选择。

reading_process

reading_process,阅读进度。大家可能注意到有些站点的最上侧会出现一个细细的进度条,代表页面加载进度和阅读进度,如果大家想设置的话也可以试试,我将其打开了,修改 _config.yml 如下:

1
2
3
4
5
6
reading_progress:
enable: true
# Available values: top | bottom
position: top
color: "#222"
height: 2px

设置之后显示效果如下:

bookmark

书签,可以根据阅读历史记录,在下次打开页面的时候快速帮助我们定位到上次的位置,大家可以根据喜好开启和关闭,我的配置如下:

1
2
3
4
5
6
7
bookmark:
enable: false
# Customize the color of the bookmark.
color: "#222"
# If auto, save the reading progress when closing the page or clicking the bookmark-icon.
# If manual, only save it by clicking the bookmark-icon.
save: auto

github_banner

在一些技术博客上,大家可能注意到在页面的右上角有个 GitHub 图标,点击之后可以跳转到其源码页面,可以为 GitHub Repository 引流,大家如果想显示的话可以自行选择打开,我的配置如下:

1
2
3
4
5
# `Follow me on GitHub` banner in the top-right corner.
github_banner:
enable: true
permalink: https://github.com/NightTeam/nightteam.github.io
title: NightTeam GitHub

记得修改下链接 permalink 和标题 title,显示效果如下: 可以看到在页面右上角显示了 GitHub 的图标,点击可以进去到 Repository 页面。

gitalk

由于 Hexo 的博客是静态博客,而且也没有连接数据库的功能,所以它的评论功能是不能自行集成的,但可以集成第三方的服务。 Next 主题里面提供了多种评论插件的集成,有 changyan | disqus | disqusjs | facebook_comments_plugin | gitalk | livere | valine | vkontakte 这些。 作为一名程序员,我个人比较喜欢 gitalk,它是利用 GitHub 的 Issue 来当评论,样式也比较不错。 首先需要在 GitHub 上面注册一个 OAuth Application,链接为:https://github.com/settings/applications/new,注册完毕之后拿到 Client ID、Client Secret 就可以了。 首先需要在 _config.yml 文件的 comments 区域配置使用 gitalk:

1
2
3
4
5
6
7
# Multiple Comment System Support
comments:
# Available values: tabs | buttons
style: tabs
# Choose a comment system to be displayed by default.
# Available values: changyan | disqus | disqusjs | facebook_comments_plugin | gitalk | livere | valine | vkontakte
active: gitalk

主要是 comments.active 字段选择对应的名称即可。 然后找打 gitalk 配置,添加它的各项配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Gitalk
# Demo: https://gitalk.github.io
# For more information: https://github.com/gitalk/gitalk
gitalk:
enable: true
github_id: NightTeam
repo: nightteam.github.io # Repository name to store issues
client_id: {your client id} # GitHub Application Client ID
client_secret: {your client secret} # GitHub Application Client Secret
admin_user: germey # GitHub repo owner and collaborators, only these guys can initialize gitHub issues
distraction_free_mode: true # Facebook-like distraction free mode
# Gitalk's display language depends on user's browser or system environment
# If you want everyone visiting your site to see a uniform language, you can set a force language value
# Available values: en | es-ES | fr | ru | zh-CN | zh-TW
language: zh-CN

配置完成之后 gitalk 就可以使用了,点击进入文章页面,就会出现如下页面: GitHub 授权登录之后就可以使用了,评论的内容会自动出现在 Issue 里面。

pangu

我个人有个强迫症,那就是写中文和英文的时候中间必须要留有间距,一个简单直接的方法就是中间加个空格,但某些情况下可能习惯性不加或者忘记加了,这就导致中英文混排并不是那么美观。 pangu 就是来解决这个问题的,我们只需要在主题里面开启这个选项,在编译生成页面的时候,中英文之间就会自动添加空格,看起来更加美观。 具体的修改如下:

1
pangu: true

math

可能在一些情况下我们需要写一个公式,比如演示一个算法推导过程,MarkDown 是支持公式显示的,Hexo 的 Next 主题同样是支持的。 Next 主题提供了两个渲染引擎,分别是 mathjax 和 katex,后者相对前者来说渲染速度更快,而且不需要 JavaScript 的额外支持,但后者支持的功能现在还不如前者丰富,具体的对比可以看官方文档:https://theme-next.org/docs/third-party-services/math-equations。 所以我这里选择了 mathjax,通过修改配置即可启用:

1
2
3
4
5
6
7
8
9
10
11
12
13
math:
enable: true

# Default (true) will load mathjax / katex script on demand.
# That is it only render those page which has `mathjax: true` in Front-matter.
# If you set it to false, it will load mathjax / katex srcipt EVERY PAGE.
per_page: true

# hexo-renderer-pandoc (or hexo-renderer-kramed) required for full MathJax support.
mathjax:
enable: true
# See: https://mhchem.github.io/MathJax-mhchem/
mhchem: true

mathjax 的使用需要我们额外安装一个插件,叫做 hexo-renderer-kramed,另外也可以安装 hexo-renderer-pandoc,命令如下:

1
2
npm un hexo-renderer-marked --save
npm i hexo-renderer-kramed --save

另外还有其他的插件支持,大家可以到官方文档查看。

pjax

可能大家听说过 Ajax,没听说过 pjax,这个技术实际上就是利用 Ajax 技术实现了局部页面刷新,既可以实现 URL 的更换,有可以做到无刷新加载。 要开启这个功能需要先将 pjax 功能开启,然后安装对应的 pjax 依赖库,首先修改 _config.yml 修改如下:

1
pjax: true

然后安装依赖库,切换到 next 主题下,然后安装依赖库:

1
2
$ cd themes/next
$ git clone https://github.com/theme-next/theme-next-pjax source/lib/pjax

这样 pjax 就开启了,页面就可以实现无刷新加载了。 另外关于 Next 主题的设置还有挺多的,这里就介绍到这里了,更多的主题设置大家可以参考官方文档:https://theme-next.org/docs/

文章

现在整个站点只有一篇文章,那么我们怎样来增加其他的文章呢? 这个很简单,只需要调用 Hexo 提供的命令即可,比如我们要新建一篇「HelloWorld」的文章,命令如下:

1
hexo new hello-world

创建的文章会出现在 source/_posts 文件夹下,是 MarkDown 格式。 在文章开头通过如下格式添加必要信息:

1
2
3
4
5
6
7
8
9
10
11
\---
title: 标题 # 自动创建,如 hello-world
date: 日期 # 自动创建,如 2019-09-22 01:47:21
tags:
- 标签1
- 标签2
- 标签3
categories:
- 分类1
- 分类2
---

开头下方撰写正文,MarkDown 格式书写即可。 这样在下次编译的时候就会自动识别标题、时间、类别等等,另外还有其他的一些参数设置,可以参考文档:https://hexo.io/zh-cn/docs/writing.html

标签页

现在我们的博客只有首页、文章页,如果我们想要增加标签页,可以自行添加,这里 Hexo 也给我们提供了这个功能,在根目录执行命令如下:

1
hexo new page tags

执行这个命令之后会自动帮我们生成一个 source/tags/index.md 文件,内容就只有这样子的:

1
2
3
4
\---
title: tags
date: 2019-09-26 16:44:17
---

我们可以自行添加一个 type 字段来指定页面的类型:

1
2
type: tags
comments: false

然后再在主题的 _config.yml 文件将这个页面的链接添加到主菜单里面,修改 menu 字段如下:

1
2
3
4
5
6
7
8
9
menu:
home: / || home
#about: /about/ || user
tags: /tags/ || tags
#categories: /categories/ || th
archives: /archives/ || archive
#schedule: /schedule/ || calendar
#sitemap: /sitemap.xml || sitemap
#commonweal: /404/ || heartbeat

这样重新本地启动看下页面状态,效果如下: 可以看到左侧导航也出现了标签,点击之后右侧会显示标签的列表。

分类页

分类功能和标签类似,一个文章可以对应某个分类,如果要增加分类页面可以使用如下命令创建:

1
hexo new page categories

然后同样地,会生成一个 source/categories/index.md 文件。 我们可以自行添加一个 type 字段来指定页面的类型:

1
2
type: categories
comments: false

然后再在主题的 _config.yml 文件将这个页面的链接添加到主菜单里面,修改 menu 字段如下:

1
2
3
4
5
6
7
8
9
menu:
home: / || home
#about: /about/ || user
tags: /tags/ || tags
categories: /categories/ || th
archives: /archives/ || archive
#schedule: /schedule/ || calendar
#sitemap: /sitemap.xml || sitemap
#commonweal: /404/ || heartbeat

这样页面就会增加分类的支持,效果如下:

搜索页

很多情况下我们需要搜索全站的内容,所以一个搜索功能的支持也是很有必要的。 如果要添加搜索的支持,需要先安装一个插件,叫做 hexo-generator-searchdb,命令如下:

1
npm install hexo-generator-searchdb --save

然后在项目的 _config.yml 里面添加搜索设置如下:

1
2
3
4
5
search:
path: search.xml
field: post
format: html
limit: 10000

然后在主题的 _config.yml 里面修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Local search
# Dependencies: https://github.com/wzpan/hexo-generator-search
local_search:
enable: true
# If auto, trigger search by changing input.
# If manual, trigger search by pressing enter key or search button.
trigger: auto
# Show top n results per article, show all results by setting to -1
top_n_per_article: 5
# Unescape html strings to the readable one.
unescape: false
# Preload the search data when the page loads.
preload: false

这里用的是 Local Search,如果想启用其他是 Search Service 的话可以参考官方文档:https://theme-next.org/docs/third-party-services/search-services

404 页面

另外还需要添加一个 404 页面,直接在根目录 source 文件夹新建一个 404.md 文件即可,内容可以仿照如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
\---
title: 404 Not Found
date: 2019-09-22 10:41:27
---

<center>
对不起,您所访问的页面不存在或者已删除。
您可以<a href="https://blog.nightteam.cn>">点击此处</a>返回首页。
</center>

<blockquote class="blockquote-center">
NightTeam
</blockquote>

这里面的一些相关信息和链接可以替换成自己的。 增加了这个 404 页面之后就可以 完成了上面的配置基本就完成了大半了,其实 Hexo 还有很多很多功能,这里就介绍不过来了,大家可以直接参考官方文档:https://hexo.io/zh-cn/docs/ 查看更多的配置。

部署脚本

最后我这边还增加了一个简易版的部署脚本,其实就是重新 gererate 下文件,然后重新部署。在根目录下新建一个 deploy.sh 的脚本文件,内容如下:

1
2
3
hexo clean
hexo generate
hexo deploy

这样我们在部署发布的时候只需要执行:

1
sh deploy.sh

就可以完成博客的更新了,非常简单。

自定义域名

将页面修改之后可以用上面的脚本重新部署下博客,其内容便会跟着更新。 另外我们也可以在 GitHub 的 Repository 里面设置域名,找到 Settings,拉到下面,可以看到有个 GitHub Pages 的配置项,如图所示: 下面有个 custom domain 的选项,输入你想自定义的域名地址,然后添加 CNAME 解析就好了。 另外下面还有一个 Enforce HTTPS 的选项,GitHub Pages 会在我们配置自定义域名之后自动帮我们配置 HTTPS 服务。刚配置完自定义域名的时候可能这个选项是不可用的,一段时间后等到其可以勾选了,直接勾选即可,这样整个博客就会变成 HTTPS 的协议的了。 另外有一个值得注意的地方,如果配置了自定义域名,在目前的情况下,每次部署的时候这个自定义域名的设置是会被自动清除的。所以为了避免这个情况,我们需要在项目目录下面新建一个 CNAME 文件,路径为 source/CNAME,内容就是自定义域名。 比如我就在 source 目录下新建了一个 CNAME 文件,内容为:

1
blog.nightteam.cn

这样避免了每次部署的时候自定义域名被清除的情况了。 以上就是从零搭建一个 Hexo 博客的流程,希望对大家有帮助。

技术杂谈

so 文件调用

随着 Android 移动安全的高速发展,不管是为了执行效率还是程序的安全性等,关键代码下沉 native 层已成为基本操作。 native 层的开发就是通指的 JNI/NDK 开发,通过 JNI 可以实现 java 层和 native 层(主要是 C/C++ )的相互调用,native 层经编译后产生 so 动态链接库,so 文件具有可移植性广,执行效率高,保密性强等优点。 那么问题来了,如何调用 so 文件显得异常重要,当然你也可以直接分析 so 文件的伪代码,利用强悍的编程功底直接模拟关键操作,但是我想对于普通人来说头发还是比较重要的。 当前调用 so 文件的主流操作应该是: 1,基于 Unicorn 的各种实现(还在学习中,暂且不表) 2,Android 服务器的搭建,在 App 内起 http 服务完成调用 so 的需求(当然前提是过了 so 的效验等操作) 至于为什么选用 AndServer,好吧,不为什么,只是因为搜索到了它 为什么结合 Service,在学习 Android 开发的时候了解到了 Service 的生命周期,个人理解用 Service 去创建 Http 服务比较好。 当然也有 Application 的简单使用,因为在正式环境中,大多数 so 文件的逻辑中都有 context 的一些包名了,签名了的效验等,自定义 Application 的话获取 context 传参就好了。

libyemu.so 简介

这是我编译好的一个 so 文件,就是根据入参做下简单的字符串拼接(以下是 native 层编译前的 c 代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extern "C"
JNIEXPORT jstring JNICALL
Java_com_fw_myapplication_ndktest_NdkTest_stringFromUTF(JNIEnv *env, jobject instance, jstring str_) {
jclass String_clazz = env->FindClass("java/lang/String");

jmethodID concat_methodID = env->GetMethodID(String_clazz, "concat", "(Ljava/lang/String;)Ljava/lang/String;");

jstring str = env->NewStringUTF(" from so --[NightTeam夜幕]");

jobject str1 = env->CallObjectMethod(str_, concat_methodID, str);

const char *chars = env->GetStringUTFChars((jstring)str1, 0);

return env->NewStringUTF(chars);
}

这部分代码还是有必要贴一下的,简单的静态注册使用了反射的思想,反射在逆向中至关重要 接下来是 java 代码,定义了 native 函数

1
2
3
4
5
6
7
8
9
package com.fw.myapplication.ndktest;

public class NdkTest {
public static native String stringFromUTF(String str);

static {
System.loadLibrary("yemu");
}
}

如果到这里有点懵逼的同学可能需要去补下 Android 开发基础了

Android 项目测试 so

先说下我的环境,因为这个环境影响太大了 1,AndroidStudio 3.4 2,手机 Android 6 架构 armeabi-v7a 打开 AndroidStudio 新建 project 在 module 的 build 中加这么一句,然后 sync 把编译好的 so 文件复制到 libs 文件夹下(和刚才的 jniLibs.srcDirs 对应) 把 so 对应的 java 代码也 copy 过来,注意包名类名的一致性 打开 activity_main.xml 文件为 TextView 添加 id 打开 MainActiviy.java 开始编码 这两行的意思就是,先从布局中找到对应 id 的 TextView,然后为其设置 Text(调用 native 函数的返回值) 下面测试一下咱们的 so 调用情况 可以看到咱们的 so 文件调用成功(这里咱们的 so 没有效验,只是测试 app 是否可以正常调用)

AndServer 代码编写

AndServer 官方文档:https://yanzhenjie.com/AndServer/ 打开官方文档,看看人家的入门介绍,新建 java 文件 如图经典 MVC 的 C 就写好了,定义了一个 nightteam_sign 接口,请求方式为 get,请求参数为 sign,调用 native 函数,然后返回 json,但是这里我想利用 Application 获取下 context 对象,取下包名,接下来自定义 Applictaion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.nightteam.httpso;

import android.app.Application;

public class MyApp extends Application {
private static MyApp myApp;
public static MyApp getInstance() {
return myApp;
}

@Override
public void onCreate() {
super.onCreate();
myApp = this;
}
}

然后在 manifest 文件中指定要启动的 Application 然后修改 MyController.java 的代码 接下来把官方文档-服务器的代码 copy 下来 导入一些包,修改部分代码如下 新版本的 AndServer.serverBuilder 已经需要传递 context 了,这里把网络地址和端口号也修改为从构造参数中获取,到这里 AndServer 的东西基本完了,实际上咱们就搭建一个调 so 的接口,并没有过多的业务逻辑,所以代码就是使用的最简单的

Service 代码编写

咱们这里用按钮的点击事件启动 Service,故在 activity_main.xml 中添加一个 button 并指定点击事件 接下来编写自定义 Service 代码

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
package com.nightteam.httpso.Service;

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;

import com.nightteam.httpso.ServerManager;

import java.net.InetAddress;
import java.net.UnknownHostException;

public class MyService extends Service {
private static final String TAG = "NigthTeam";

@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "onCreate: MyService");
new Thread() {
@Override
public void run() {
super.run();
InetAddress inetAddress = null;
try {
inetAddress = InetAddress.getByName("0.0.0.0");
Log.d(TAG, "onCreate: " + inetAddress.getHostAddress());
ServerManager serverManager = new ServerManager(getApplicationContext(), inetAddress, 8005);
serverManager.startServer();
} catch (UnknownHostException e) {
e.printStackTrace();
}

}
}.start();
}

@Override
public IBinder onBind(Intent intent) {
return null;
}

}

打上了几个 log,在子线程中启动 AndServer 的服务(何时使用 UI 线程和子线程是 Android 基础,这里就不赘述了) 注意一下,这里从 0.0.0.0 获取 inetAddress,可不要写错了,localhost 和 0.0.0.0 的区别请移步搜索引擎 然后就是向 ServerManager 的构造函数传递 context,inetAddress,port 用来 new 对象,随后开启服务 最后注意检查下 manifest 文件中 Service 的声明

开启 Service,并获取本机 ip

回到我们的 MainActivity.java 的 operate( button 的点击事件)编写启动 Service 代码

1
2
3
4
5
6
7
8
9
10
11
12
public void operate(View view) {
switch (view.getId()){
case R.id.id_bt_index:
//启动服务:创建-->启动-->销毁
//如果服务已经创建了,后续重复启动,操作的都是同一个服务,不会再重新创建了,除非你先销毁它
Intent it1 = new Intent(this, MyService.class);
Log.d(TAG, "operate: button");
startService(it1);
((Button) view).setText("服务已开启");
break;
}
}

到这里我们的服务基本搭建好了,但是为了方便起见,我想把咱们的本机 ip 显示在 App 上,这样我们就不用去设置再查看了 我在网上找到了一个获取 ip 地址的一个工具类,源码如下:

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
package com.nightteam.httpso;

import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.Enumeration;
import java.util.regex.Pattern;

public class NetUtils {

private static final Pattern IPV4_PATTERN = Pattern.compile("^(" +

"([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}" +

"([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$");

private static boolean isIPv4Address(String input) {

return IPV4_PATTERN.matcher(input).matches();

}

//获取本机IP地址

public static InetAddress getLocalIPAddress() {

Enumeration<NetworkInterface> enumeration = null;

try {

enumeration = NetworkInterface.getNetworkInterfaces();

} catch (SocketException e) {

e.printStackTrace();

}

if (enumeration != null) {

while (enumeration.hasMoreElements()) {

NetworkInterface nif = enumeration.nextElement();

Enumeration<InetAddress> inetAddresses = nif.getInetAddresses();

if (inetAddresses != null)

while (inetAddresses.hasMoreElements()) {

InetAddress inetAddress = inetAddresses.nextElement();

if (!inetAddress.isLoopbackAddress() && isIPv4Address(inetAddress.getHostAddress())) {

return inetAddress;

}

}
}

}

return null;

}
}

把工具类 copy 到我们的 Android 项目中,继续在 MainActivity.java 中编码 获取了一下本机地址和 Android SDK 版本( Android 8 之后启动 Service 方式不一样)

申请权限,启动 App

最后一步就是为 app 申请网络权限了 随后连接我们的手机,运行项目,测试一下,点击开启服务 看下 AndroidStudio 日志 好像一切正常,在浏览器访问下试试( ip 就是 App 中显示的 ip 地址) 如图正常访问到了我们想要的内容 回过头来说下 Service,打开我们手机的设置,找到应用程序管理-运行中的服务(手机不同,方式不同) 可以看到我们的程序,运行了一个服务,这个服务就是咱们编码的 MyService 接下来杀掉该 App进程,再次查看运行中的服务 我这里在权限管理设置了自动运行,可以保持服务的运行。(这个地方还是根据系统有大小差异) 至此使用 App 起 http 服务调 so 就完成了


好了,上面就是利用 AndServer 打造 Android 服务器调 so 文件的整体思路和流程,如果你懒得看的话,直接用我写好的 App 修修补补也是可以的,只需要发送消息【AndServer搭建Web服务调so】到公众号【NightTeam】即可。


文章作者:「夜幕团队 NightTeam 」- 妄为 夜幕团队成立于 2019 年,团队成员包括崔庆才、周子淇、陈祥安、唐轶飞、冯威、蔡晋、戴煌金、张冶青和韦世东。 涉猎的主要编程语言为 Python、Rust、C++、Go,领域涵盖爬虫、深度学习、服务研发和对象存储等。团队非正亦非邪,只做认为对的事情,请大家小心。

技术杂谈

这是系列文章的第一篇,也是非常重要的一篇,希望大家能读懂我想要表达的意思。

系列文章开篇概述

相对于其他编程语言来说,Python 生态中最突出的就是第三方库。任何一个及格的 Python 开发者都使用过至少 5 款第三方库。 就爬虫领域而言,必将用到的例如网络请求库 Requests、网页解析库 Parsel 或 BeautifulSoup、数据库对象关系映射 Motor 或 SQLAlchemy、定时任务 Apscheduler、爬虫框架 Scrapy 等。 这些开源库的使用方法想必大家已经非常熟练了,甚至还修炼出了自己的一套技巧,日常工作中敲起键盘肯定也是哒哒哒的响。 但是你有没有想过:

  • 那个神奇的功能是如何实现的?
  • 这个功能背后的逻辑是什么?
  • 为什么要这样做而不是选择另一种写法?
  • 编写这样的库需要用到哪些知识?
  • 这个论点是否有明确的依据?

如果你从未这样想过,那说明你还没到达应该「渡劫」的时机;如果你曾提出过 3 个以上的疑问,那说明你即将到达那个重要的关口;如果你常常这么想,而且也尝试着寻找对应的答案,那么恭喜你,你现在正处于「渡劫」的关口之上。 偶有群友会抛出这样的问题:初级工程师、中级工程师、高级工程师如何界定? 这个问题有两种不同的观点,第一个是看工作职级,第二个则是看个人能力。工作职级是一个浮动很大的参照物,例如阿里巴巴的高级研发和我司的高级研发,职级名称都是「高级研发」,但能力可能会有很大的差距。 个人能力又如何评定呢? 难不成看代码写的快还是写的慢吗? 当然不是! 个人能力应当从广度和深度两个方面进行考量,这并没有一个明确的标准。当两人能力差异很大的时候,外人可以轻松的分辨孰强孰弱。 自己怎样分辨个人能力的进与退呢? 这就回到了上面提到的那些问题:WHO WHAT WHERE WHY WHEN HOW? 我想通过这篇文章告诉你,不要做那个用库用得很熟练的人,要做那个创造库的人。计算机世界如此吸引人,就是因为我们可以在这个世界里尽情创造。 你想做一个创造者吗? 如果不想,那现在你就可以关掉浏览器窗口,回到 Hub 的世界里。

内容介绍

这是一套系列文章,这个系列将为大家解读常见库(例如 WebSocket、HTTP、ASCII、Base64、MD5、AES、RSA)的协议规范和对应的代码实现,帮助大家「知其然,知其所以然」。

目标

这次我们要学习的是 WebSocket 协议规范和代码实现,也可以理解为从 0 开始编写 aiowebsocket 库。至于为什么选择它,那大概是因为全世界没有比我更熟悉的它的人了。 我是 aiowebsocket 库的作者,我花了 7 天编写这个库。写库的过程,让我深刻体会到造轮子和驾驶的区别,也让我有了飞速的进步。我希望用连载系列文章的形式帮助大家从驾驶者转换到创造者,拥有「编程思考」。

前置条件

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,它的出现使客户端和服务器之间的数据交换变得更加简单。下图描述了双端交互的流程: WebSocket 通常被应用在实时性要求较高的场景,例如赛事数据、股票证券、网页聊天和在线绘图等。WebSocket 与 HTTP 协议完全不同,但同样被广泛应用。 无论是后端开发者、前端开发者、爬虫工程师或者信息安全工作者,都应该掌握 WebSocket 协议的知识。 我曾经发表过几篇关于 WebSocket 的文章:

其中,《【严选-高质量文章】开发者必知必会的 WebSocket 协议》介绍了协议规范的相关知识。这篇文章的内容大体如下:

  • WebSocket 协议来源
  • WebSocket 协议的优点
  • WebSocket 协议规范
  • 一些实际代码演示

如果没有掌握 WebSocket 协议的朋友,我建议先去阅读这篇文章,尤其是对 WebSocket 协议规范介绍的那部分。 要想将协议规范 RFC6455 变成开源库,第一步就是要熟悉整个协议规范,所以你需要阅读【严选-高质量文章】开发者必知必会的 WebSocket 协议。当然,有能力的同学直接阅读 RFC6455 也未尝不可。 接着还需要了解编程语言中内置库 Socket 的基础用法,例如 Python 中的 socket 或者更高级更潮的 StreamsTransports and Protocols。如果你是 Go 开发者、Rust 开发者,请查找对应语言的内置库。 假设你已经熟悉了 RFC6455,你应该知道 Frame 打包和解包的时候需要用到位运算,正好我之前写过位运算相关的文章 7分钟全面了解位运算。 至于其它的,现用现学吧!

Python 网络通信之 Streams

WebSocket,也可以理解为在 WEB 应用中使用的 Socket,这意味着本篇将会涉及到 Socket 编程。上面提到,Python 中与 Socket 相关的有 socket、Streams、Transports and Protocols。其中 socket 是同步的,而另外两个是异步的,这俩属于你常听到的 asyncio。

Socket 通信过程

Socket 是端到端的通信,所以我们要搞清楚消息是怎么从一台机器发送到另一台机器的,这很重要。假设通信的两台机器为 Client 和 Server,Client 向 Server 发送消息的过程如下图所示:

Client 通过文件描述符的读写 API read & write 来访问操作系统内核中的网络模块为当前套接字分配的发送 send buffer 和接收 recv buffer 缓存。 Client 进程写消息到内核的发送缓存中,内核将发送缓存中的数据传送到物理硬件 NIC,也就是网络接口芯片 (Network Interface Circuit)。 NIC 负责将翻译出来的模拟信号通过网络硬件传递到服务器硬件的 NIC。 服务器的 NIC 再将模拟信号转成字节数据存放到内核为套接字分配的接收缓存中,最终服务器进程从接收缓存中读取数据即为源客户端进程传递过来的 消息。

上述通信过程的描述和图片均出自钱文品的深入理解 RPC 交互流程。 我尝试寻找通信过程中每个步骤的依据(尤其是 send buffer to NIC to recv buffer),(我翻阅了 TCP 的 RFC 和 Kernel.org)但遗憾的是并未找到有力的证明(一定是我太菜了),如果有朋友知道,可以评论告诉我或发邮件 zenrusts@sina.com 告诉我,我可以扩展出另一篇文章。

创建 Streams

那么问题来了:在 Python 中,我们如何实现端到端的消息发送呢? 答:Python 提供了一些对象帮助我们实现这个需求,其中相对简单易用的是 Streams。 Streams 是 Python Asynchronous I/O 中提供的 High-level APIs。Python 官方文档对 Streams 的介绍如下:

Streams are high-level async/await-ready primitives to work with network connections. Streams allow sending and receiving data without using callbacks or low-level protocols and transports.

我尬译一下:Streams 是用于网络连接的 high-level async/await-ready 原语。Streams 允许在不使用回调或 low-level protocols and transports 的情况下发送和接收数据。 Python 提供了 asyncio.open_connection() 让开发者创建 Streams,asyncio.open_connection() 将建立网络连接并返回 reader 和 writer 对象,这两个对象其实是 StreamReader 和 StreamWriter 类的实例。 开发者可以通过 StreamReader 从 IO 流中读取数据,通过 StreamWriter 将数据写入 IO 流。虽然文档并没有给出 IO 流的明确定义,但我猜它跟 buffer (也就是 send buffer to NIC to recv buffer 中的 buffer)有关,你也可以抽象的认为它就是 buffer。 有了 Streams,就有了端到端消息发送的完整实现。下面将通过一个例子来熟悉 Streams 的用法和用途。这是 Python 官方文档给出的双端示例,首先是 Server 端:

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
# TCP echo server using streams
# 本文出自「夜幕团队 NightTeam」 转载请联系并取得授权
import asyncio

async def handle_echo(reader, writer):
data = await reader.read(100)
message = data.decode()
addr = writer.get_extra_info('peername')

print(f"Received {message!r} from {addr!r}")

print(f"Send: {message!r}")
writer.write(data)
await writer.drain()

print("Close the connection")
writer.close()

async def main():
server = await asyncio.start_server(
handle_echo, '127.0.0.1', 8888)

addr = server.sockets[0].getsockname()
print(f'Serving on {addr}')

async with server:
await server.serve_forever()

asyncio.run(main())

接着是 Client 端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# TCP echo client using streams
# 本文出自「夜幕团队 NightTeam」 转载请联系并取得授权
import asyncio

async def tcp_echo_client(message):
reader, writer = await asyncio.open_connection(
'127.0.0.1', 8888)

print(f'Send: {message!r}')
writer.write(message.encode())

data = await reader.read(100)
print(f'Received: {data.decode()!r}')

print('Close the connection')
writer.close()

asyncio.run(tcp_echo_client('Hello World!'))

将示例分别写入到 server.py 和 client.py 中,然后按序运行。此时 server.py 的窗口会输出如下内容:

1
2
3
4
Serving on ('127.0.0.1', 8888)
Received 'Hello World!' from ('127.0.0.1', 59534)
Send: 'Hello World!'
Close the connection

从输出中得知,服务启动的 address 和 port 为 ('127.0.0.1', 8888),从 ('127.0.0.1', 59534) 读取到内容为 Hello World! 的消息,接着将 Hello World! 返回给 ('127.0.0.1', 59534) ,最后关闭连接。 client.py 的窗口输出内容如下:

1
2
3
Send: 'Hello World!'
Received: 'Hello World!'
Close the connection

在创建连接后,Client 向指定的端发送了内容为 Hello World! 的消息,接着从指定的端接收到内容为 Hello World! 的消息,最后关闭连接。 有些读者可能不太理解,为什么 Client Send Hello World! ,而 Server 接收到之后也向 Client Send Hello World! 。双端的 Send 和 Received 都是 Hello World! ,这很容易让新手懵逼。实际上这就是一个普通的回显服务器示例,也就是说当 Server 收到消息时,将消息内容原封不动的返回给 Client。 这样只是为了演示,并无它意,但这样的示例却会给新手带来困扰。 以上是一个简单的 Socket 编程示例,整体思路理解起来还是很轻松的,接下来我们将逐步解读示例中的代码:

1
2
3
* client.py 中用 `asyncio.open_connection()` 连接指定的端,并获得 reader 和 writer 这两个对象。
* 然后使用 writer 对象中的 `write()` 方法将 `Hello World!` 写入到 IO 流中,该消息会被发送到 Server。
* 接着使用 reader 对象中的 `read()` 方法从 IO 流中读取消息,并将消息打印到终端。

看到这里,你或许会有另一个疑问:write() 只是将消息写入到 IO 流,并没有发送行为,那消息是如何传输到 Server 的呢? 由于无法直接跟进 CPython 源代码,所以我们无法得到确切的结果。但我们可以跟进 Python 代码,得知消息最后传输到 transport.write() ,如果你想知道更多,可以去看 Transports and Protocols 的介绍。你可以将这个过程抽象为上图的 Client to send buffer to NIC to recv buffer to Server。

功能模块设计

通过上面的学习,现在你已经掌握了 WebSocket 协议规范和 Python Streams 的基本用法,接下来就可以设计一个 WebSocket 客户端库了。 根据 RFC6455 的约定,WebSocket 之前是 HTTP,通过「握手」来升级协议。协议升级后进入真正的 WebSocket 通信,通信包含发送(Send)和接收(Recv)。文本消息要在传输过程前转换为 Frames,而接受端读取到消息后要将 Frames 转换成文本。当然,期间会有一些异常产生,我们可能需要自定义异常,以快速定位问题所在。现在我们得出了几个模块:

1
2
3
4
5
6
7
* 握手 - ShakeHands

* 传输 - Transports

* 帧处理 - Frames

* 异常 - Exceptions

一切准备就绪后,就可以进入真正的编码环节了。 由于实战编码篇幅太长,我决定放到下一期,这期的内容,读者们可能需要花费一些时间吸收。

小结

开篇我强调了「创造能力」有多么重要,甚至抛出了一些不是很贴切的例子,但我就是想告诉你,不要做调参?。 然后我告诉你,本篇文章要讲解的是 WebSocket。 接着又跟你说,要掌握 WebSocket 协议,如果你无法独立啃完 RFC6455,还可以看我写过的几篇关于 WebSocket 文章和位运算文章。 过了几分钟,给你展示了 Socket 的通信过程,虽然没有强有力的依据,但你可以假设这是对的。 喝了一杯白开水之后,我向你展示了 Streams 的具体用法并为你解读代码的作用,重要的是将 Streams 与 Socket 通信过程进行了抽象。 这些前置条件都确定后,我又带着你草草地设计了 WebSocket 客户端的功能模块。 下一篇文章将进入代码实战环节,请做好环境(Python 3.6+)准备。

总之,要想越过前面这座山,就请跟我来!


文章作者:「夜幕团队 NightTeam 」- 韦世东 夜幕团队成立于 2019 年,团队成员包括崔庆才、周子淇、陈祥安、唐轶飞、冯威、蔡晋、戴煌金、张冶青和韦世东。 涉猎的主要编程语言为 Python、Rust、C++、Go,领域涵盖爬虫、深度学习、服务研发和对象存储等。团队非正亦非邪,只做认为对的事情,请大家小心。

技术杂谈

时间过得真快,距离这个系列的上一篇文章《商业级4G代理搭建指南【准备篇】》发布的时间已经过了两个星期了,上个星期由于各种琐事缠身,周二开始就没空写文章了,所以就咕咕咕了。 那么在准备篇中,我们了解了一下搭建 4G 代理所需要的软硬件,也知道了各种选择的优劣势。现在,我们就可以开始实际搭建了,相信大家也是期待已久了。


基本思路

从这篇文章的标题中我们可以看出,这一次的搭建方案主要用到的是 Docker,你可能会很好奇,Docker 跟搭建 4G 代理有什么关系吗? 嗯,关系很大,我们把整件事情梳理一下,先来看看搭建 4G 代理时的基本流程:

  1. 调用网卡拨号,拨号成功后会创建一个虚拟网卡。(正常情况下使用这个虚拟网卡就能上网了)
  2. 在多网卡的情况下,重复第一步,会得到多个虚拟网卡。
  3. 启动代理服务器,使其使用虚拟网卡作为出网网卡,并使用接入内网的实体网卡作为入网网卡。 使用起来差不多是这样的

但是呢,有个问题,根据我之前的测试结果来看,目前在 Linux 环境下还没有一个 HTTP 代理服务器是可以做到分别指定出网网卡和入网网卡的,嗯…这就很麻烦了,因为如果我们无法这么做的话,就会出现类似于下面这样的问题:

  1. 出网和入网都在虚拟网卡上,使用代理服务器必须要走公网访问。
  2. 入网为实体网卡,但出网被代理服务器锁定为了某一个,无法利用到多网卡。

嗯…那么不用 HTTP 代理服务器,用那些经常被用来做一些骚操作的 Socks5 代理服务器呢?如果可以指定网卡的话,再用像 Privoxy 之类的工具把 Socks5 代理转成 HTTP 代理就好了。(某知名扶墙软件的 Windows 版本就是这么转的 HTTP 代理) 在经过一番尝试后,我发现虽然有些 Socks5 代理服务器的文档中是说可以指定网卡,但按照说明操作后,似乎并不能直接做到我想要的效果(要么还是锁定在某一个上面、要么上不了网),所以还是存在一些问题的。可能是需要配合路由表设置来进行操作吧,不过我对网络工程的了解不怎么深,搞了几天也没搞出来,于是乎还得想想别的办法。 这时候,我想到了一个东西——Docker,它可以用来解决这个问题! 因为 Docker 容器被创建后,不管外界的网卡有多少个,容器内部的网卡都只会有一个Docker自己的虚拟网卡(容器间通信用的)和一个本地环回接口(不用管它),而且我们在容器内进行拨号操作时,产生的那个新的虚拟网卡也不会影响到外界或其他容器,这样的话,代理服务器就不需要指定网卡了,直接启动就能跑! 那么现在整个流程就跑通了,进入实际操作环节看看吧!


系统方面

这个 Docker 版的搭建方式,系统方面的选择很多,由于我使用的样例设备是树莓派,所以这里就选择使用了 Raspbian(树莓派专属版 Debian)。如果你使用的是其他设备的话,直接选择一个自己常用的系统就好。 那么准备好之后的第一步当然是先下载并安装 Docker,这里我直接使用 Docker 官方提供的一键安装脚本来进行安装:

1
2
3
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# 出自官方文档:https://docs.docker.com/install/linux/docker-ce/debian/#install-using-the-convenience-script

这个一键安装脚本理论上来讲所有 Linux 发行版都可以使用,毕竟已经出来很长时间了,如果不行的话请自行使用搜索引擎查找相关资料。 装好 Docker 之后,你有两个选择:

  1. 进入体验模式,了解一下具体操作细节是怎么样的。
  2. 不看这一段,翻到本文最下方直接使用我写好的轮子。

启动容器

体验的话,我们就直接这么启动一个 Docker 容器吧,执行以下命令:

1
sudo docker run -it --rm --privileged -p 3128:3128 ubuntu:18.04 bash

上面这条命令的意思是,启动一个内部系统为 Ubuntu18.04 的容器,并进入容器内部的 Shell 执行 bash 命令,如果退出 bash 就自动销毁容器;然后映射容器内的端口3128到外界,映射出来的外界端口也是3128;最后 privileged 参数是开启特权模式,用于将网卡设备映射进容器内。 如果下载镜像很慢的话,可以搜一下“Docker 加速器”,也可以直接扶墙。

测试一下网卡是否正常

进入容器内部后,我们可以执行一下 ls /dev/ttyUSB* 看一下网卡有没有正常被识别出来(在容器外也是一样的,因为开了特权模式),如果是和我买的同一款 4G 网卡的话,在只插入一张网卡的情况下你会看到4个 ttyUSB 设备。 插入了三张网卡的样子,一共12个 ttyUSB 设备

不同 4G 网卡和硬件组合可能会有差异,请以实际情况为准。

如果你可以看到4✖4G网卡个数个 ttyUSB 设备的话,就说明没有问题,可以开始下一步了。

拨号上网

接下来要做的就是拨号了,拨号方面可以选择使用 Wvdial 这种工具,也可以选择使用像 Fanconn 这样的商家提供的拨号脚本(直接调用 PPPD),使用起来的效果会有一些区别。如果商家没有提供拨号脚本的话,就用 Wvdial 吧,它能自动生成配置,上手即用。 我这边的话,由于 Fanconn 的技术人员直接提供了个拨号脚本,那我就用这个脚本了,Wvdial 的文档网上有很多很详尽的,这里就不再多提,需要的朋友自行搜索即可。 如果你用的是 Fanconn 的这个拨号脚本(怎么弄进容器内就不用我说了吧?),那么直接在 apt install ppp 安装好拨号工具之后,用 chmod +x quectel-pppd.sh 给拨号脚本加个运行权限,然后 ./quectel-pppd.sh /dev/ttyUSB3 即可。

拨号时使用的 /dev/ttyUSB3 是指 4G 网卡的第四个通信端口,文档中的解释为:ttyUSB3→For PPP connections or AT command communication,翻译一下就是用于 PPP 连接或 AT 命令通信。

拨号之后用 ifconfig 之类的工具即可看到类似下图中的状态: 可以看到,如前文所述,现在有三个网卡,一个是 Docker 自己的、一个是本地环回接口(这个不用管)、一个是拨号产生的虚拟网卡。

如果不是在 Docker 容器内使用的话,还会有个 wwan0(或其他名字),那个是 4G 网卡本体。

测试是否能正常上网

现在如果你用 curl 的 \--interface 参数指定虚拟网卡进行请求的话(如:curl --interface ppp0 https://ip.cn),是已经可以请求成功的了,IP 也会是你所使用的 SIM 卡对应的运营商分配的。

由于 Docker 的镜像通常都是极度精简的,所以 Ubuntu 镜像里并没有预装像 net-tools、iputils-ping、vim、curl 之类的这些包,需要自行安装。所以如果你发现 ifconfig、ping、curl、vim 用不了,不要惊慌,这是正常现象,执行 apt install 包名 命令安装即可。

如果你无法直接请求成功的话,就可能是 DNS 解析出问题了,可以尝试 ping 一个公网 IP(如:ping 1.1.1.1)和一个域名(如:ping ip.cn),如果 IP 能 ping 通但域名会报 DNS 解析失败的话,就可以确认是 DNS 设置问题了。 4G 拨号时如果出现 DNS 设置问题,通常是因为拨号工具没有正常地将运营商返回的 DNS 服务器设置写入到配置中,我们可以手动配置一下(你要强制指定某一个 DNS 也可以):

1
2
3
# 以下为阿里云的公共DNS
echo 'nameserver 223.5.5.5' >> /etc/resolv.conf
echo 'nameserver 223.6.6.6' >> /etc/resolv.conf

在 Docker 容器中,这个 /etc/resolv.conf 文件可能还会有两条内容,是容器本身所需要的,建议不要删除/覆盖,否则会出现容器间无法使用容器名互相通信的情况。

启动代理服务器

那么在测试拨号后确实可以通过 4G 网卡上网了之后,我们就可以把代理服务器启动了,这里我使用的是 TinyProxy。

测试发现,Squid 对资源的占用更大一些,不利于多网卡情况下的使用,会影响到 4G 网卡的数量上限。

apt install tinyproxy 一波,然后 vim /etc/tinyproxy/tinyproxy.conf 修改一下配置。 要修改的配置主要有:

  • Port 配置项改为3128,因为我们前面映射出来的端口是3128。
  • Listen 配置项改为0.0.0.0,因为我们需要在其他设备上使用这个代理服务器。
  • Allow 配置项注释掉或改为0.0.0.0/0,默认的127.0.0.1会导致其他设备无法访问。

改完之后保存一波,然后就可以直接执行 tinyproxy 启动了…吗? 等等,还有一个操作要做!那就是将默认路由指向到虚拟网卡上,很简单,执行以下命令即可:

1
2
route del -net 0.0.0.0 eth0
route add -net 0.0.0.0 ppp0

这两条命令的意思是:先将默认的、指向 eth0 这个网卡的上网路由删除,然后添加一个同样的、指向 ppp0 这个网卡的路由。 改完默认路由后的效果就是,即使你不使用 curl 的 \--interface 参数,也能直接使用 4G 网卡上网了。

如果没有改默认路由的话,在不指定网卡的情况下,4G 网卡并不会被使用到,因为默认路由指向的是 Docker 自身的虚拟网卡,那个网卡通向你原本的内网环境。也就是说,IP 不会变!

那么现在,你可以执行 tinyproxy 启动代理服务器了。

测试代理服务器

好了,代理服务器应该已经正常启动了,现在我们可以在另一个设备上尝试连接那个容器中的代理服务器,看看是否能正常通过它使用 4G 网卡上网。 例如我这里树莓派分配到的IP是:192.168.137.66,那么我就可以用这样的 curl 命令或 Python 代码进行测试: curl:

1
2
curl "https://ip.cn"
curl -x "192.168.137.66:3128" "https://ip.cn"

Python:

1
2
3
4
5
import requests
resp = requests.get("https://ip.cn", proxies={"https": "http://192.168.137.66:3128"})
no_proxy_resp = requests.get("https://ip.cn")
print(resp.text)
print(no_proxy_resp.text)

测试出来的结果应该与前面在容器内部测试时的一致,在使用代理后 IP 就变成了运营商分配的基站 IP。

更换 IP

那么最核心的问题来了,怎么更换 IP 呢? 其实和使用那些拨号 VPS 架设代理服务器一样,我们只需要重新拨个号就能换 IP 了,直接 kill 掉 pppd 进程就可以让它断开拨号,断开后重新执行一遍拨号脚本就是重新拨号了。

断开拨号方面 Fanconn 的技术人员也提供了一个脚本,同样在 chmod +x quectel-ppp-kill 赋予运行权限之后,执行 ./quectel-ppp-kill 就可以了。

但需要注意的是,蜂窝网络的拨号在断开后,IP 仍然会保留一段时间(具体多久不清楚,可能跟连接的基站也有关系),所以我们需要强制性地让网卡重新搜网。

冷门小知识:手机上开启关闭飞行模式的效果就是重新搜网,通常只是关闭“移动数据”的话,效果是与断开拨号一致的。

怎么做呢?很简单,就两行命令:

1
2
AT+CFUN=0
AT+CFUN=1

但注意哦,这是 AT 命令,不是 Linux 下的 Shell 命令,AT 命令是一种调制解调器命令语言,我们如果需要将它执行起来,需要这么做:

1
2
3
echo "AT+CFUN=0" > /dev/ttyUSB2
# 中间间隔1秒左右
echo "AT+CFUN=1" > /dev/ttyUSB2

这里使用的 /dev/ttyUSB2 是指 4G 网卡的第三个通信端口,文档中的解释为:ttyUSB2→For AT command communication,与第四个通信端口类似,只是它不能用于 PPP 连接、只能用于 AT 命令通信而已。 不同样使用第四个通信端口的原因是那个端口有被占用的可能性,直接区分开最稳妥,本来网卡也就是提供了两个 AT 命令通信渠道的。

在使网卡重新搜网后的几秒至十几/几十秒内的时间里,你无法正常拨号,需要等待它初始化完成后才可以拨号成功,具体等待时间以信号强度为准,我测试的时候通常5秒以内就可以了。 所以如果你在断开后一直拨号失败,不妨过一会儿再试。


总结

那么现在操作流程也跑通了,我们也了解到了整个的内部细节,最后要做的就是把每个网卡都分别分配一个容器,这样我们就能实现文章开头所提到的——“使用虚拟网卡作为出网网卡,并使用接入内网的实体网卡作为入网网卡”的效果了。 实际操作起来的话,就是把指定网卡的部分给配置化,然后在启动容器的时候传入就好了,使用 Docker 的容器环境变量相关设置可以很轻松地实现这个功能。 最后,我们可以以这个思路,构建一个 docker-compose 模板,模板的核心内容一是做个简易的4G网卡容器集群,二是启动个 Squid,用来聚合代理服务器,这样我们使用的时候只需要指定一个代理服务器就能随机更换了,操作起来更加方便。


好了,上面就是 Docker 版搭建方式的思路和整个的搭建流程,如果你懒得看的话,直接用我写好的轮子也是可以的,只需要发送消息【Docker版4G代理】到公众号【NightTeam】即可。

评价

最后的最后,我给这个搭建方式打个评价吧。 这个搭建方式并不完美,因为变量太多,而且很多地方肯定不如系统级原生支持的那么稳定,长期使用可能会出现各种奇奇怪怪的问题。 然后 Docker 的资源占用其实挺高的,会浪费相当多的内存在启动容器上,如果只是两三个网卡还好,如果数量大一点的话,像树莓派2B 这种小内存的设备根本就扛不住。 另外代理服务器本身对资源的消耗也是比较高的,高频调用下对树莓派2B 的小 CPU 压力还是蛮大的,即使我对它的 CPU 进行了超频,在并发测试时也还是会出现轻松打满 CPU 的情况。 但是!截止目前,我还有两种基于路由器系统的搭建方案没写出来!所以…敬请期待后续的其他搭建方案(斜眼笑)。


文章作者:「夜幕团队 NightTeam」 - Loco 夜幕团队成立于 2019 年,团队包括崔庆才、周子淇、陈祥安、唐轶飞、冯威、蔡晋、戴煌金、张冶青和韦世东。 涉猎的编程语言包括但不限于 Python、Rust、C++、Go,领域涵盖爬虫、深度学习、服务研发、对象存储等。团队非正亦非邪,只做认为对的事情,请大家小心。

技术杂谈

在这个互联网时代,拥有流量就仿佛于拥有了一切。 我大约在 2014 年底开了自己的个人博客,当时就是想自己记录点学习总结,一个是方便查阅,二是锻炼一下自己写总结或者文章的能力,最初就是记录一些日常生活、编程学习的小知识点什么的。 一次偶然的机会我接触了爬虫,当时用 Python 写爬虫的仿佛也不多,正好有一位学长有研究,我也就跟着他学了起来,学的时候也是自己总结,然后把一些文章发表到博客上,累积了十几篇左右。不知道是什么原因,渐渐地好像爬虫火了起来,Python 也火了起来,不知不觉地我发现我的博客慢慢地流量涨起来了,一天几百、几千一直到现在上万的浏览量,SEO 也逐渐好了起来,说实话我当时都没有想到,感觉还是不少运气成分在里面的。 两年前左右我开了一个公众号,开始在公众号上面发一些文章,自己也逐渐从博客转战到公众号上面了,因为公众号的环境总体来说还是很不错的,尤其是对原创作者来说非常友好,非常尊重原创。转载文章需要开白、原创命中和保护机制、洗稿检测、及时的投诉处理让越来越多的技术人员也转到公众号上来了。所以越来越多的技术开发者都拥有了自己的公众号,变成了一个人人公号的时代。这导致了一个什么结果?竞争日益激烈,读者的可选择范围太多了,大家的涨粉之路也走得越来越艰辛了。 同样地我也遭受着同样的苦恼,这时候我突然想起来,我似乎还有个博客呢?最近写文都专注于公众号,没太有心思打理自己博客了。我在想要是能够把我的博客流量转换到我的公众号上来该多好呢?一来我的博客读者可以关注到我的公号平时发的文章或通知,二来也着实能为自己的公号涨一点粉丝,这样该多好啊? 思来想去我想到了一个法子,就是在浏览博客文章的时候,把后续内容的隐藏,留一个二维码,可以通过关注公众号解锁。 当时设想效果图就是这样子的: image-20190914224503383 文章在某个位置会渐变隐藏,同时浮现一个公众号的样子,需要扫码才能解锁。这时候读者扫码自动关注了公号,博客文章也自然而然地解锁,这样博客的读者就自然关注到公号上面来了。

功能要点

一听到这样的法子,大家肯定就骂起来了,文章还要解锁来看?每篇文章都要解锁一遍吗?以后如果再打开还需要次次解锁吗? 如果真的是这样,那我情愿不做这个功能,因为这太损伤「用户体验」了。为了尽量减少用户体验的损失,这个功能必须要满足以下几点:

  • 不要添加用户登录注册机制,一旦增加了这个机制,流程可能会大大复杂化,导致用户体验急剧下降。
  • 不能每打开一个页面都要解锁一次,读者访问了我的博客,只需要一次解锁,即可全面解锁博客所有文章。
  • 读者在关闭浏览器再重新打开浏览器浏览博客的时候,同样不能让读者再解锁一遍,要直接可看。
  • 读者在手机或其他移动设备上不方便操作,手机站点禁止启用本功能。

如果满足了这些条件,读者在一篇文章里面只要扫码解锁了一次,那么就可以永久解锁全站文章了,没有繁琐的登录注册功能,也不需要次次频繁解锁,这样用户体验就非常好了。 为了达成这个目的,我就开始开发这个功能了。

识别用户

那么怎么来实现呢?要实现上面的功能,其实最重要的就是来识别是哪一个用户,也就是说,我怎么知道到底是谁在浏览我的博客呢?我怎么来专门针对这个用户解锁呢? 有的同学可能说那就用 IP 地址呗,技术角度是可以实现的,但是其实仔细想想,用 IP 地址是很不友好的。一来是很多用户可能都是内网的 IP 地址,多个公户共享一个公网 IP 地址,所以假如两台设备接入了同一个公网 IP,我是无法判断到底解锁哪一台设备的。二来是,如果一个用户换了其他的地方或者用了 VPN,IP 地址变了,原本解锁的设备又变成非解锁状态了。这样也不好。 那么最方便简单的用来标识一个浏览设备的东西是什么?当然是 Cookies。Cookies 里面保存了浏览网页时自动生成的 Session ID,而且每一个用户都是不一样的,这样不就可以来唯一标识一台浏览设备了吗?

解锁逻辑

好,那有了用户的 ID,我怎么才能把用户 ID 和我的公众号关联起来呢?当然是把这个 ID 发到公众号后台,我来存起来就好了。然后博客这边定时检测我这边有没有把这个 ID 保存,如果保存了,那就呈现解锁状态,如果没有保存,那就呈现非解锁状态。 最开始我就设想,既然公众号要扫码关注,那么我能不能把这个 ID 也糅到二维码里面呢?这样关注公众号的时候既能查询到公众号,有传递过来一个 ID 作为参数,然后后台处理一下存起来就好了。 你别说还真有这个功能,我在微信平台官方文档里面查询到了一个「生成带参数的公众号二维码」,生成的二维码里面可以指定任意的参数,然后生成的二维码图案就是公众号的二维码,然后处理一下关注公众号的回调函数就可以执行某一些操作了。看到之后我就想起来了很多关注公众号自动登录的功能就是这么做的。 但是经过一系列操作,发现了一个很悲伤的事情,只有服务号才有这个功能,我一小小的订阅号,是没有这个权限的,不能生产带参数的二维码。哎,难道凉了吗? 不,没有,既然这个参数不能通过二维码传递,那就只好麻烦读者手动把这 ID 输入到我的公众号了,我的小小的订阅号还是有处理消息的功能的。我的公众号后台接收到消息,然后处理下这个消息 ID,然后存起来,那不就好了吗? 说干就干!

隐藏文章

怎么开始做呢?那就从隐藏文章开始做吧。首先这个隐藏不能是真正的后台的隐藏,需要在前台隐藏。如果是后台隐藏的话,搜索引擎所能爬到的我的网站内容就会缺失了,会影响 SEO 的。所以只需要前台 CSS 隐藏一下就好了。 怎样看起来隐藏得比较自然呢?就取文章的的一半的地方,把文章的下面部分用 CSS 藏起来,然后加个渐变效果就好了。 比如要隐藏一半的内容吧,首先可以获取文章区块的高度,然后把文章页面高度用 CSS 强制设置为原来的一半就好了,这个很好操作,然后再在最底下加个渐变的样子,仿佛底下还有文字的样子。 这个 CSS 用 background 属性就能实现了,参考代码如下:

1
2
3
4
5
#locker {
height: 240px;
width: 100%;
background: -webkit-gradient(linear, 0 top, 0 bottom, from(rgba(255, 255, 255, 0)), to(#fff));
}

这里就是设置个 240 像素的区块,然后从上面到下面是透明度渐变颜色就好了,整体效果是下面这个样子: image-20190914231647956 好,既然隐藏了,那么下面就加个提示吧,把公众号的二维码先放上,然后把那个 Session ID 放上,提示用户关注公众号后发送这个 ID 就能解锁了,但这个 ID 又不能太长,多少呢?六位吧那就。 类似做成这样的样子: image-20190914231805506 好,那么这个 ID 怎么获取的呢? 刚才说了,从 Cookies 里面获取就行了,找那个能够标识 Session ID 的一个 Cookies 字段,然后摘取其值的其中几位就行了,摘取的位置也有讲究,前几位仿佛重复率很高的样子,后面几位几乎不重复,那就截取最后六位数字吧。 好,然后我就在博客里面加了这么一点 JavaScript 代码来实现这个 ID 的提取:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length == 2) return parts.pop().split(";").shift();
}

function getToken() {
let value = getCookie('UM_distinctid')
if (!value) {
return defaultToken
}
return value.substring(value.length - 6).toUpperCase()
}

这里 getCookie 方法是用某个名字获取一个 Cookies 字段,getToken 方法是截取了 Cookies 这个字段值的后六位并做了大写处理。 这里我的一个可以用来标识 Session ID 的 Cookies 字段叫做 UM_distinctid,就用它了。 这样一来,每个用户浏览的时候就能生成这样的一个 ID 了,六位的。 胜利似乎越来越近了。

持久化存储

这里就又遇到一个问题,刚才不是说还要在用户关闭浏览器之后再重新打开,依然能保持解锁状态吗?这就要求这个 ID 在用户关闭又打开浏览器的时候是不变的。 这个怎么解?很简单,反正已经是从 Cookies 里面读了,这个 Cookies 持久化就行了,只要不在浏览器关闭后清除就行了,怎么办?设置个过期时间就好。 由于我的站点是 WordPress 做的,所以这个功能自动有了,如果没有的话用一些插件也能实现的。

公众号处理

好,现在 ID 也有了,用户扫码把这个 ID 发到公众号后台就行了吧,然后公众号对接开发者模式处理一下就好了。 这里就其实就很简单了,其实仅仅就是把用户的 OpenID 和这个码存到了一个数据库里面。我后台是用 Django 写的,所以用了 Django 里面的 Model,实现逻辑如下:

1
2
3
4
5
6
7
8
9
10
def unlock(source, target, content):
"""
解锁博客
:param target: 微信平台
:param source: 用户
:param content: 用户发来的码
:return:
"""
Unlock.objects.get_or_create(openid=source, token=content.upper())
return reply_text(source, target, '恭喜您已经解锁博客全部文章~')

就是这么两行,插入了一条数据,然后返回了一个信息提示。 插入之后怎么办呢?博客得知道我已经把这条数据插入进来了呀?那就再提供一个 API 查询吧,实现如下:

1
2
3
4
5
6
7
8
9
10
11
def is_locked(request):
"""
判断是否已经解锁
:param request: 包含token的请求
:return:
"""
token = request.GET.get('token')
result = Unlock.objects.filter(token=token.upper()).first()
return JsonResponse({
'locked': False if result else True
})

把这个方法对接一个 API 接口,比如 /api/locked?token=xxxxx,就可以知道是否解锁了。 所以,在公众号后台我就用开发者模式对接了这么两个功能,一个用来存,一个用来查。只要用户发送了这个能够用来表示自己浏览设备的码,我就存下来,然后博客定时请求这个 API 查询状态,如果返回结果是未解锁状态,那就继续锁住,如果是解锁状态。那就把博客解开。

博客端处理

那么博客端具体怎么来处理呢?就基本的轮询就好了,定时几秒查一次 API,然后把这个码当做参数传过去,然后根据查询结果执行解锁或非解锁操作就好了。 核心代码如下:

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
(articleSelector).ready(function () {
var articleElement = $(articleSelector)[0]
if (articleElement) {
var height = articleElement.clientHeight
var halfHeight = height * 0.3
var token = getToken()
$('#locker').find('.token').text(token)
function detect() {
$.ajax({
url: 'https://weixin.cuiqingcai.com/api/locked/',
method: 'GET',
data: {
token: token
},
success: function (data) {
if (data.locked === true || data.locked === false) {
locked = data.locked
}
},
error: function (data) {
locked = false
}
})
}
}
})

这里就用基本的 jQuery 实现的,其实就是调了个 Ajax,也没啥高深的技巧。这里唯一值得注意的一点设计就是,如果 API 请求失败,这基本上证明我的 API 服务挂掉了,这里就需要把 locked 设置为 false,证明为解锁状态。这样,万一我的 API 后台挂了,博客会直接是解锁状态,这样就避免了读者永远无法解锁了。这是一个细节上的设计。 至此,一些技术上的问题就基本解决了。

手机端处理

最后回过头来看看,那个需求还没有满足? 读者在手机或其他移动设备上不方便操作,手机站点禁止启用本功能。那么怎么实现呢?很简单,判断一下浏览器的 User-Agent 就好了,这里实现了一个判断是否是 PC 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var os = function () {
var ua = navigator.userAgent,
isWindowsPhone = /(?:Windows Phone)/.test(ua),
isSymbian = /(?:SymbianOS)/.test(ua) || isWindowsPhone,
isAndroid = /(?:Android)/.test(ua),
isFireFox = /(?:Firefox)/.test(ua),
isChrome = /(?:Chrome|CriOS)/.test(ua),
isTablet = /(?:iPad|PlayBook)/.test(ua) || (isAndroid && !/(?:Mobile)/.test(ua)) || (isFireFox && /(?:Tablet)/.test(ua)),
isPhone = /(?:iPhone)/.test(ua) && !isTablet,
isPc = !isPhone && !isAndroid && !isSymbian;
return {
isTablet: isTablet,
isPhone: isPhone,
isAndroid: isAndroid,
isPc: isPc
}
}()

这样一来,调用 os.isPC 就可以知道当前浏览器是不是手机浏览器了。 在处理的时候加上这个条件判断,就可以实现手机功能的解除了。

效果

可能大家想知道效果是如何的,这里就截图看看了,现在这个功能已经在我的博客 cuiqingcai.com 上线了,大家可以进去体验一下。 首先进去文章是这个样子的: image-20190914234237147 然后关注了公号,发送了代码: image-20190914234421831 发送完毕之后,大约一两秒之后,抬头看看博客,就是这个样子了: image-20190914234406442 这已经就完成了解锁和转化,读者可以全站永久解锁我的博客文章,我也增长了粉丝。 现在过一段时间就会有读者发来代码解锁,同时成为了我的粉丝,订阅号助手看到消息如下: image-20190914234720018 以上便是这个博客转化的思路分享和实现,大家也可以到我的博客体验一下,谢谢!

技术杂谈

今天这篇文章是要告诉你,业内多名实力强劲的开发者组建了一个服务于广大开发者的团队。现在,你可能会有下面这些疑问:

  • 为什么要组成团队呢?
  • 这个团队将会给广大开发者带来什么?
  • 团队成员有哪些?

好了,接下来用 3 分钟时间去了解这个团队吧!

夜幕团队简介

夜幕团队 NightTeam 于 2019 年 9 月 9 日正式成立,团队由爬虫领域中实力强劲的多名开发者组成:崔庆才、周子淇、陈祥安、唐轶飞、冯威、蔡晋、戴煌金、张冶青和韦世东。 NightTeam 涉猎的编程语言包括但不限于 Python、Rust、C++、Go,领域涵盖爬虫、深度学习、服务研发和对象存储等,团队技术实力十分雄厚。

为什么要组建一个团队?

据以往经验来看,产出一篇优质的技术文章所耗费的时间是相当长的,读者很难从单个作者那里获得成体系且覆盖面较广的知识。 对于作者而言,粉丝的积累、文章的持续编写都是一个慢跑的过程,很多优秀的作者会因为没有粉丝、投入产出比差距太大等因素暂停写作。 更有甚者,文章内容质量越来越低、标题越来越唬、广告越来越多,一门心思放在如何涨粉和变现上。如此循环,就会影响技术圈和技术生态的发展。 如果将那些优秀的作者联合起来(即合纵连横),共同运营一系列的技术媒体号,在不增加负担的情况下还能保证优质文章的产出,会怎么样呢?

我们也不知道,但是我们已经意识到了这个问题,也尝试着寻找答案。这就是夜幕团队 NightTeam 组建的大环境背景。

团队希望做什么事?

互联网是开放的,技术也是开放的。适当的共享可以加速我们的进步,无论是作者还是读者,都能够从分享的过程中获得一些东西。

在开发中,我们会用到一些库和框架。当我刚编程入门的时候,就想着有一天也要编写开源项目,为其他开发者提供帮助,这正是夜幕团队想要做的事。 当然,我们想要做的不仅仅是编写一个开源项目,我们更希望将知识分享出去,在技术圈中传播开来,让更多的人得到帮助。

团队能够为开发者带来什么?

读者的能力和阶段各不相同,每个人收获的内容也不同。宽泛来说,读者可以从夜幕团队 NightTeam 输出的内容中获得对待问题的处理方式、分析问题的思路、解决问题的技巧、问题背后的逻辑等。 假如你是一名中级爬虫工程师,那么夜幕团队 NightTeam 输出的内容可能会为你解决工作中遇到的一些反爬虫问题带来思路。

如果你是一名后端开发者,你也许会从我们发布的类似于【动图演示 - Redis 持久化 RDB/AOF 详解与实践】这样的文章中了解到 Redis 持久化的两种选择的差异和具体操作过程。

团队以什么形式输出内容?

大多数情况下,我们会用文章的形式输出内容。但有时候也会采用直播或者视频教程的方式,兴许还会有现场交流的机会。 为了增加传播广度和影响力,我们不仅仅在微信公众号上发布文章,还会将文章同步到业内著名的几个平台,例如掘金社区、CSDN、SF、V2EX、知乎、今日头条等。

团队成员介绍

团队的成员都是爬虫领域比较活跃的作者,同时也拥有非常强的实力,不会是那些只负责回答小白问题骗钱的盗名之辈。以下将不分先后的列出团队成员姓名、昵称和各自的介绍。 周子淇

昵称 Loco 前微信公众号「小周码字」号主、知乎专栏「手把手教你写爬虫」作者,幂度爬虫工程师。啥方向都搞,除了爬虫相关的文章以外还会写一些灰黑产操作研究、机器学习、造轮子、物联网设备研究、脑洞分享等各种奇奇怪怪的东西。

韦世东

昵称 Asyncins 图灵签约作者、电子工业出版社约稿作者、华为云认证云享专家、掘金社区优秀作者、GitChat 认证作者、搜狐产品技术约稿作者、开源项目 aiowebsocket 作者、微信公众号「Rust之禅」号主、「进击的Coder」运营者之一,有着丰富的爬虫经验,擅长反爬虫的绕过技巧。

崔庆才

昵称 静觅 畅销书《Python3网络爬虫开发实战》作者、微信公众号「进击的Coder」号主,微软中国工程师。主要研究网络爬虫、机器学习、Web 开发相关内容。

陈祥安

昵称 CXA 微信公众号「Python学习开发」号主、CSDN 线下沙龙特邀讲师、华为云享社区专家、阿里云栖社区专家、哔哩哔哩《陈祥安分析Python面试题》系列 UP 主、GitChat 热门文章《Python 常见的 170 道面试题全解析:2019 版》作者,马蜂窝高级爬虫工程师。

唐轶飞

昵称 大鱼 BruceDone 微信公众号「大鱼鱼塘」号主、「http://brucedone.com」站长,腾讯后端工程师。多年 Code 经验,擅长后端开发,语言了解但不限于 .NET、Python、Golang、SQL,兴趣包含但不限于爬虫,后端,数据库,深度学习。

冯 威

昵称 妄为 微信公众号「妄为写代码」号主,爬虫 Coder,佛系程序员。专注 JavaScript、Android 逆向以及验证码破解,对逆向有着丰富的经验。

蔡 晋

昵称 悦来客栈的老板 微信公众号「菜鸟学Python编程」号主,平时喜欢研究各大网站的反爬,熟悉常见网站的反爬操作,对反爬有着独到的见解。

戴煌金

昵称 咸鱼 微信公众号「咸鱼学Python」号主、华为云享专家。专注Python爬虫、JavaScript逆向,立志做一条最咸的咸鱼。

张冶青

昵称:MarvinZ 微信公众号「Crawlab漫游指南」号主、爬虫管理平台 Crawlab 作者、文章发布平台 ArtiPub 作者,知名外企前端开发工程师。专注前端、爬虫和数据分析。

如何与夜幕取得联系?

你可能想跟夜幕团队交流一些技术方面的问题,你可以发送消息“夜幕读者群”到我们的公众号「NightTeam」加入读者群,团队成员都在群里等你。 一些重要的事可以通过邮件与夜幕取得联系,夜幕团队的邮箱为 contact@nightteam.cn。 GitHub 也准备好了,我们会逐渐将开源项目迁移到团队的仓库中,地址为 https://github.com/nightteam

技术杂谈

舆情爬虫是网络爬虫一个比较重要的分支,舆情爬虫往往需要爬虫工程师爬取几百几千个新闻站点。比如一个新闻页面我们需要爬取其标题、正文、时间、作者等信息,如果用传统的方式来实现,每一个站点都要配置非常多的规则,如果要维护一个几百上千的站点,那人力成本简直太高了。 如果有一种方式可以在保证差不多的准确率的前提下,大幅提高提取效率的话,就需要用到智能文本提取了。 本文首先介绍一下智能文本提取的基本原理,让大家对智能提取有基本的了解。然后介绍几个比较基础的工具包,准确率并不是很高,可以尝试一用。最后再介绍几篇比较前沿的技术供大家参考。

智能文本提取

目前来说,智能文本提取可以分为三类:

  • 基于网页文档内容的提取方法
  • 基于 DOM 结构信息的提取方法
  • 基于视觉信息的提取方法

基于网页文档的提取方法将 HTML 文档视为文本进行处理,适用于处理含有大量文本信息且结构简单易于处理的单记录网页,或者具有实时要求的在线分析网页应用。 这种方式主要利用自然语言处理相关技术实现,通过理解 文本语义、分析上下文、设定提取规则等,实现对大段网页文档的快速处理。其中,较为知名的方法有 TSIMMIS、Web-OQL、Serrano、FAR-SW 和 FOREST,但这些方法由于通常需要人工的参与,且存在耗时长、效率低的弊端。 基于 DOM 结构信息的方法将 HTML 文档解析为相应的 DOM 树,然后根据 DOM 树的语法结构创建提取规则, 相对于以前的方法而言有了更高的性能和准确率。 W4F 和 XWRAP 将 HTML 文档解析成 DOM 树,然后通过组件化引导用户通过人工选择或者标记生成目标包装器代码。Omini、IEPAD 和 ITE 提取 DOM 树上的关键路径, 获取其中存在的重复模式。MDR 和 DEPTA 挖掘了页面中的数据区域,得到数据记录的模式。CECWS 通过聚类算法从数据库中提取出自同一网站的一组页面,并进行 DOM 树结构的对比,删除其中的静态部分,保留动态内容作为信息提取的结果。虽然此类方法相对于上一类方法 具有较高的提取精度,且克服了对大段连续文本的依赖, 但由于网页的 DOM 树通常较深,含有大量 DOM 节点, 因此基于 DOM 结构信息的方法具有较高的时间和空间消耗。目前来说,大部分原理还是基于 DOM 节点的文本密度、标点符号密度等计算的,其准确率还是比较可观的。今天所介绍的 Readability 和 Newspaper 的库的实现原理就是类似。 目前比较先进的是基于视觉信息的网页信息提取方法,通过浏览器接口或者内核对目标网页预渲染,然后基于网页的视觉规律提取网页数据记录。经典的 VIPS 算法首先从 DOM 树中提取出所有合适的页面区域,然后根据这些页面和分割条重新构建 Web 页面的语义结构。作为对 VIPS 的拓展,ViNT、ViPER、ViDE 也成功利用了网页的视觉特征来实现数据提取。CMDR 为通过神经网络学习多记录型页面中的特征,结合基于 DOM 结构信息的 MDR 方法,挖掘社区论坛页面的数据区域。与上述方法不同,VIBS 将图像领域的 CNN 卷积神经网络运用于网页的截图,同时通过类 VIPS 算法生成视觉块,最后结合两个阶段的结果识别网页的正文区域。另外还有最新的国内提出的 VBIE 方法,基于网页视觉的基础上改进,可以实现无监督的网页信息提取。

以上内容主要参考自论文:《王卫红等:基于可视块的多记录型复杂网页信息提取算法》,算法可从该论文参考文献查阅。

下面我们来介绍两个比较基础的工具包 Readability 和 Newspaper 的用法,这两个包经我测试其实准确率并不是很好,主要是让大家大致对智能解析有初步的理解。后面还会介绍一些更加强大的智能化解析算法。

Readability

Readability 实际上是一个算法,并不是一个针对某个语言的库。其主要原理就是计算了 DOM 的文本密度,另外根据一些常见的 DOM 属性如 id、class 等计算了一些 DOM 的权重,最后分析得到了对应的 DOM 区块,进而提取出具体的文本内容。 现在搜索 Readability 其实已经找不到了,取而代之的是一个 JavaScript 工具包,叫做 mercury-parser,据我所知应该是 Readability 不维护了,换成了 mercury-parser。后者现在也做成了一个 Chrome 插件,大家可以下载使用一下。 回归正题,这次主要介绍的是 Python 的 Readability 实现,现在其实有很多开源版本,本文选取的是 https://github.com/buriy/python-readability,是基于最早的 Python 版本的 Readability 库 https://github.com/timbertson/python-readability 二次开发的,现在已经发布到了 PyPi,大家可以直接下载安装使用。 安装很简单,通过 pip 安装即可:

1
pip3 install readability-lxml

安装好了之后便可以通过导入 readability 使用了,下面我们随便从网上找一个新闻页面,比如:https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html,其页面截图如下页面示例 我们的目的就是它的正文、标题等内容。下面我们用 Readability 试一下,示例如下:

1
2
3
4
5
6
7
8
import requests
from readability import Document

url = 'https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html'
html = requests.get(url).content
doc = Document(html)
print('title:', doc.title())
print('content:', doc.summary(html_partial=True))

在这里我们直接用 requests 库对网页进行了请求,获取了其 HTML 页面内容,赋值为 html。 然后引入了 readability 里的 Document 类,使用 html 变量对其进行初始化,然后我们分别调用了 title 方法和 summary 方法获得了其标题和正文内容。 这里 title 方法就是获取文章标题的,summary 就是获取文章正文的,但是它获取的正文可能包含一些 HTML 标签。这个 summary 方法可以接收一个 html_partial 参数,如果设置为 True,返回的结果则不会再带有 <html><body> 标签。 看下运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
title: 今年iPhone只有小改进?分析师:还有其他亮点_网易科技
content: <div><div class="post_text" id="endText">
<p class="otitle">
(原标题:Apple Bets More Cameras Can Keep iPhone Humming)
</p>
<p class="f_center"><img alt="今年iPhone只有小改进?分析师:还有其他亮点" src="http://cms-bucket.ws.126.net/2019/09/09/d65ba32672934045a5bfadd27f704bc1.jpeg"/><span>图示:苹果首席执行官蒂姆·库克(Tim Cook)在6月份举行的苹果全球开发者大会上。</span></p><p>网易科技讯 9月9日消息,据国外媒体报道,和过去的12个年头一样,新款
... 中间省略 ...
<p>苹果还即将推出包括电视节目和视频游戏等内容的新订阅服务。分析师表示,该公司最早可能在本周宣布TV+和Arcade等服务的价格和上线时间。</p><p>Strategy Analytics的尼尔·莫斯顿(Neil Mawston)表示,可穿戴设备和服务的结合将是苹果业务超越iPhone的关键。他说,上一家手机巨头诺基亚公司在试图进行类似业务转型时就陷入了困境之中。(辰辰)</p><p><b>相关报道:</b></p><p><a href="https://tech.163.com/19/0908/09/EOHS53RK000999LD.html" target="_self" urlmacroreplace="false">iPhone 11背部苹果Logo改为居中:为反向无线充电</a></p><p><a href="https://tech.163.com/19/0907/08/EOF60CBC00097U7S.html" target="_self" urlmacroreplace="false">2019年新iPhone传言汇总,你觉得哪些能成真</a>  </p><p/>
<p/>
<div class="ep-source cDGray">
<span class="left"><a href="http://tech.163.com/"><img src="https://static.ws.126.net/cnews/css13/img/end_tech.png" alt="王凤枝" class="icon"/></a> 本文来源:网易科技报道 </span>
<span class="ep-editor">责任编辑:王凤枝_NT2541</span>
</div>
</div>
</div>

可以看到,标题提取是正确的。正文其实也是正确的,不过这里还包含了一些 HTML 标签,比如 <img><p> 等,我们可以进一步通过一些解析库来解析。 看下源码吧,比如提取标题的方法:

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
def normalize_entities(cur_title):
entities = {
u'u2014':'-',
u'u2013':'-',
u'&mdash;': '-',
u'&ndash;': '-',
u'u00A0': ' ',
u'u00AB': '"',
u'u00BB': '"',
u'&quot;': '"',
}
for c, r in entities.items():
if c in cur_title:
cur_title = cur_title.replace(c, r)

return cur_title

def norm_title(title):
return normalize_entities(normalize_spaces(title))

def get_title(doc):
title = doc.find('.//title')
if title is None or title.text is None or len(title.text) == 0:
return '[no-title]'

return norm_title(title.text)

def title(self):
"""Returns document title"""
return get_title(self._html(True))

title 方法实际上就是调用了一个 get_title 方法,它怎么做的?实际上就是用了一个 XPath 只解析了 <title> 标签里面的内容,别的没了。如果没有,那就返回 [no-title]

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
def summary(self, html_partial=False):
ruthless = True
while True:
self._html(True)
for i in self.tags(self.html, 'script', 'style'):
i.drop_tree()
for i in self.tags(self.html, 'body'):
i.set('id', 'readabilityBody')
if ruthless:
self.remove_unlikely_candidates()
self.transform_misused_divs_into_paragraphs()
candidates = self.score_paragraphs()

best_candidate = self.select_best_candidate(candidates)

if best_candidate:
article = self.get_article(candidates, best_candidate,
html_partial=html_partial)
else:
if ruthless:
ruthless = False
continue
else:
article = self.html.find('body')
if article is None:
article = self.html
cleaned_article = self.sanitize(article, candidates)
article_length = len(cleaned_article or '')
retry_length = self.retry_length
of_acceptable_length = article_length >= retry_length
if ruthless and not of_acceptable_length:
ruthless = False
continue
else:
return cleaned_article

这里我删除了一些冗余的调试的代码,只保留了核心的代码,其核心实现就是先去除一些干扰内容,然后找出一些疑似正文的 candidates,然后再去寻找最佳匹配的 candidates 最后提取其内容返回即可。 然后再找到获取 candidates 方法里面的 score_paragraphs 方法,又追踪到一个 score_node 方法,就是为每一个节点打分的,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def score_node(self, elem):
content_score = self.class_weight(elem)
name = elem.tag.lower()
if name in ["div", "article"]:
content_score += 5
elif name in ["pre", "td", "blockquote"]:
content_score += 3
elif name in ["address", "ol", "ul", "dl", "dd", "dt", "li", "form", "aside"]:
content_score -= 3
elif name in ["h1", "h2", "h3", "h4", "h5", "h6", "th", "header", "footer", "nav"]:
content_score -= 5
return {
'content_score': content_score,
'elem': elem
}

这什么意思呢?你看如果这个节点标签是 div 或者 article 等可能表征正文区块的话,就加 5 分,如果是 aside 等表示侧栏的内容就减 3 分。这些打分也没有什么非常标准的依据,可能是根据经验累积的规则。 另外还有一些方法里面引用了一些正则匹配来进行打分或者替换,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 REGEXES = {
'unlikelyCandidatesRe': re.compile('combx|comment|community|disqus|extra|foot|header|menu|remark|rss|shoutbox|sidebar|sponsor|ad-break|agegate|pagination|pager|popup|tweet|twitter', re.I),
'okMaybeItsACandidateRe': re.compile('and|article|body|column|main|shadow', re.I),
'positiveRe': re.compile('article|body|content|entry|hentry|main|page|pagination|post|text|blog|story', re.I),
'negativeRe': re.compile('combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|outbrain|promo|related|scroll|shoutbox|sidebar|sponsor|shopping|tags|tool|widget', re.I),
'divToPElementsRe': re.compile('<(a|blockquote|dl|div|img|ol|p|pre|table|ul)', re.I),
#'replaceBrsRe': re.compile('(<br[^>]*>[ nrt]*){2,}',re.I),
#'replaceFontsRe': re.compile('<(/?)font[^>]*>',re.I),
#'trimRe': re.compile('^s+|s+$/'),
#'normalizeRe': re.compile('s{2,}/'),
#'killBreaksRe': re.compile('(<brs*/?>(s|&nbsp;?)*){1,}/'),
'videoRe': re.compile('https?://(www.)?(youtube|vimeo).com', re.I),
#skipFootnoteLink: /^s*([?[a-z0-9]{1,2}]?|^|edit|citation needed)s*$/i,
}

比如这里定义了 unlikelyCandidatesRe,就是不像 candidates 的 pattern,比如 foot、comment 等等,碰到这样的标签或 pattern 的话,在计算分数的时候都会减分,另外还有其他的 positiveRe、negativeRe 也是一样的原理,分别对匹配到的内容进行加分或者减分。 这就是 Readability 的原理,是基于一些规则匹配的打分模型,很多规则其实来源于经验的累积,分数的计算规则应该也是不断地调优得出来的。 另外其他的就没了,Readability 并没有提供提取时间、作者的方法,另外此种方法的准确率也是有限的,但多少还是省去了一些人工成本。

Newspaper

另外还有一个智能解析的库,叫做 Newspaper,提供的功能更强一些,但是准确率上个人感觉和 Readability 差不太多。 这个库分为 Python2 和 Python3 两个版本,Python2 下的版本叫做 newspaper,Python3 下的版本叫做 newspaper3k,这里我们使用 Python3 版本来进行测试。 其 GitHub 地址是:https://github.com/codelucas/newspaper,官方文档地址是:[https://newspaper.readthedocs.io](https://newspaper.readthedocs.io/>)。 在安装之前需要安装一些依赖库,可以参考官方的说明:https://github.com/codelucas/newspaper#get-it-now。 安装好必要的依赖库之后,就可以使用 pip 安装了:

1
pip3 install newspaper3k

安装成功之后便可以导入使用了。 下面我们先用官方提供的实例来过一遍它的用法,官方提供的示例是使用了这个链接:https://fox13now.com/2013/12/30/new-year-new-laws-obamacare-pot-guns-and-drones/,其页面截图如下官方示例 下面用一个实例来感受一下:

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

url = 'https://fox13now.com/2013/12/30/new-year-new-laws-obamacare-pot-guns-and-drones/'
article = Article(url)
article.download()
# print('html:', article.html)

article.parse()
print('authors:', article.authors)
print('date:', article.publish_date)
print('text:', article.text)
print('top image:', article.top_image)
print('movies:', article.movies)

article.nlp()
print('keywords:', article.keywords)
print('summary:', article.summary)

这里从 newspaper 库里面先导入了 Article 这个类,然后直接传入 url 即可,首先需要调用它的 download 方法,将网页爬取下来,否则直接进行解析会抛出错误的。

但我总感觉这个设计挺不友好的,parse 方法不能判断下,如果没执行 download 就自动执行 download 方法吗?如果不 download 其他的不什么都干不了吗?

好的,然后我们再执行 parse 方法进行网页的智能解析,这个功能就比较全了,能解析 authors、publish_date、text 等等,除了正文还能解析作者、发布时间等等。 另外这个库还提供了一些 NLP 的方法,比如获取关键词、获取文本摘要等等,在使用前需要先执行以下 nlp 方法。 最后运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
authors: ['Cnn Wire']
date: 2013-12-30 00:00:00
text: By Leigh Ann Caldwell

WASHINGTON (CNN) — Not everyone subscribes to a New Year’s resolution, but Americans will be required to follow new laws in 2014.

Some 40,000 measures taking effect range from sweeping, national mandates under Obamacare to marijuana legalization in Colorado, drone prohibition in Illinois and transgender protections in California.

Although many new laws are controversial, they made it through legislatures, public referendum or city councils and represent the shifting composition of American beliefs.
...
...
Colorado: Marijuana becomes legal in the state for buyers over 21 at a licensed retail dispensary.

(Sourcing: much of this list was obtained from the National Conference of State Legislatures).
top image: https://localtvkstu.files.wordpress.com/2012/04/national-news-e1486938949489.jpg?quality=85&strip=all
movies: []
keywords: ['drones', 'national', 'guns', 'wage', 'law', 'pot', 'leave', 'family', 'states', 'state', 'latest', 'obamacare', 'minimum', 'laws']
summary: Oregon: Family leave in Oregon has been expanded to allow eligible employees two weeks of paid leave to handle the death of a family member.
Arkansas: The state becomes the latest state requiring voters show a picture ID at the voting booth.
Minimum wage and former felon employmentWorkers in 13 states and four cities will see increases to the minimum wage.
New Jersey residents voted to raise the state’s minimum wage by $1 to $8.25 per hour.
California is also raising its minimum wage to $9 per hour, but workers must wait until July to see the addition.

这里省略了一些输出结果。 可以看到作者、日期、正文、关键词、标签、缩略图等信息都被打印出来了,还算是不错的。 但这个毕竟是官方的实例,肯定是好的,我们再测试一下刚才的例子,看看效果如何,网址还是:https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html,改写代码如下

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

url = 'https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html'
article = Article(url, language='zh')
article.download()
# print('html:', article.html)

article.parse()
print('authors:', article.authors)
print('title:', article.title)
print('date:', article.publish_date)
print('text:', article.text)
print('top image:', article.top_image)
print('movies:', article.movies)

article.nlp()
print('keywords:', article.keywords)
print('summary:', article.summary)

这里我们将链接换成了新闻的链接,另外在 Article 初始化的时候还加了一个参数 language,其值为 zh,代表中文。 然后我们看下运行结果:

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
Building prefix dict from /usr/local/lib/python3.7/site-packages/jieba/dict.txt ...
Dumping model to file cache /var/folders/1g/l2xlw12x6rncs2p9kh5swpmw0000gn/T/jieba.cache
Loading model cost 1.7178938388824463 seconds.
Prefix dict has been built succesfully.
authors: []
title: 今年iPhone只有小改进?分析师:还有其他亮点
date: 2019-09-09 08:10:26+08:00
text: (原标题:Apple Bets More Cameras Can Keep iPhone Humming)

图示:苹果首席执行官蒂姆·库克(Tim Cook)在6月份举行的苹果全球开发者大会上。

网易科技讯 99日消息,据国外媒体报道,和过去的12个年头一样,新款iPhone将成为苹果公司本周所举行年度宣传活动的主角。但人们的注意力正转向需要推动增长的其他苹果产品和服务。
...
...
Strategy Analytics的尼尔·莫斯顿(Neil Mawston)表示,可穿戴设备和服务的结合将是苹果业务超越iPhone的关键。他说,上一家手机巨头诺基亚公司在试图进行类似业务转型时就陷入了困境之中。(辰辰)

相关报道:

iPhone 11背部苹果Logo改为居中:为反向无线充电

2019年新iPhone传言汇总,你觉得哪些能成真
top image: https://www.163.com/favicon.ico
movies: []
keywords: ['trust高级投资组合经理丹摩根dan', 'iphone', 'mawston表示可穿戴设备和服务的结合将是苹果业务超越iphone的关键他说上一家手机巨头诺基亚公司在试图进行类似业务转型时就陷入了困境之中辰辰相关报道iphone', 'xs的销售疲软状况迫使苹果在1月份下调了业绩预期这是逾15年来的第一次据贸易公司susquehanna', 'xs机型发布后那种令人失望的业绩重演iphone', '今年iphone只有小改进分析师还有其他亮点', 'more', 'xr和iphone', 'morgan说他们现在没有任何真正深入的进展只是想继续让iphone这款业务继续转下去他乐观地认为今年发布的新款手机将有足够多的新功能为一个非常成熟的产品增加额外的功能让火车继续前进这种仅限于此的态度说明了苹果自2007年发布首款iphone以来所面临的挑战iphone销售占苹果公司总营收的一半以上这让苹果陷入了一个尴尬的境地既要维持核心产品的销量另一方面又需要减少对它的依赖瑞银ubs今年5月份对8000名智能手机用户进行了相关调查其发布的年度全球调查报告显示最近iphone在人脸识别技术等方面的进步并没有引起一些消费者的共鸣他们基本上都认为苹果产品没有过去几年那么独特或者惊艳品牌也没有过去几年那么有吸引力很多人使用老款手机的时间更长自己认为也没有必要升级到平均售价949美元的新款iphone苹果需要在明年销售足够多的iphone以避免像去年9月份iphone', 'keep', '原标题apple']
summary: (原标题:Apple Bets More Cameras Can Keep iPhone Humming)图示:苹果首席执行官蒂姆·库克(Tim Cook)在6月份举行的苹果全球开发者大会上。网易科技讯 99日消息,据国外媒体报道,和过去的12个年头一样,新款iPhone将成为苹果公司本周所举行...亚公司在试图进行类似业务转型时就陷入了困境之中。(辰辰)相关报道:iPhone 11背部苹果Logo改为居中:为反向无线充电2019年新iPhone传言汇总,你觉得哪些能成真

中间正文很长省略了一部分,可以看到运行时首先加载了一些中文的库包,比如 jieba 所依赖的词表等等。 解析结果中,日期的确是解析对了,因为这个日期格式的的确比较规整,但这里还自动给我们加了东八区的时区,贴心了。作者没有提取出来,可能是没匹配到 来源 两个字吧,或者词库里面没有,标题、正文的提取还算比较正确,也或许这个案例的确是比较简单。 另外对于 NLP 部分,获取的关键词比较迷,长度有点太长了。summary 也有点冗余。 另外 Newspaper 还提供了一个较为强大的功能,就是 build 构建信息源。官方的介绍其功能就是构建一个新闻源,可以根据传入的 URL 来提取相关文章、分类、RSS 订阅信息等等。 我们用实例感受一下:

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

source = newspaper.build('http://www.sina.com.cn/', language='zh')
for category in source.category_urls():
print(category)

for article in source.articles:
print(article.url)
print(article.title)

for feed_url in source.feed_urls():
print(feed_url)

在这里我们传入了新浪的官网,调用了 build 方法,构建了一个 source,然后输出了相关的分类、文章、RSS 订阅等内容,运行结果如下:

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
http://cul.news.sina.com.cn
http://www.sina.com.cn/
http://sc.sina.com.cn
http://jiangsu.sina.com.cn
http://gif.sina.com.cn
....
http://tj.sina.com.cn
http://travel.sina.com.cn
http://jiaoyi.sina.com.cn
http://cul.sina.com.cn
https://finance.sina.com.cn/roll/2019-06-12/doc-ihvhiqay5022316.shtml
经参头版:激发微观主体活力加速国企改革
http://eladies.sina.com.cn/feel/xinli/2018-01-25/0722/doc-ifyqwiqk0463751.shtml
我们别再联系了
http://finance.sina.com.cn/roll/2018-05-13/doc-ihamfahx2958233.shtml
新违约时代到来!违约“常态化”下的市场出清与换血
http://sports.sina.com.cn/basketball/2019worldcup/2019-09-08/doc-iicezzrq4390554.shtml
罗健儿26分韩国收首胜
...
http://travel.sina.com.cn/outbound/pages/2019-09-05/detail-iicezzrq3622449.shtml
菲律宾海滨大道 夜晚让人迷离
http://travel.sina.com.cn/outbound/pages/2016-08-19/detail-ifxvcnrv0334779.shtml
关岛 用双脚尽情享受阳光与海滩
http://travel.sina.com.cn/domestic/pages/2019-09-04/detail-iicezzrq3325092.shtml
秋行查干浩特草原
http://travel.sina.com.cn/outbound/pages/2019-09-03/detail-iicezueu3050710.shtml
白羊座的土豪之城迪拜
http://travel.sina.com.cn/video/baidang/2019-08-29/detail-ihytcitn2747327.shtml
肯辛顿宫藏着维多利亚的秘密
http://cd.auto.sina.com.cn/bdcs/2017-08-15/detail-ifyixias1051586.shtml

可以看到它输出了非常多的类别链接,另外还有很多文章列表,由于没有 RSS 订阅内容,这里没有显示。 下面把站点换成我的博客:https://cuiqingcai.com,博客截图如下博客截图 看看运行结果:

1
2
https://cuiqingcai.com
https://cuiqingcai.com

似乎不太行啊,一篇文章都没有,RSS 也没有,可见其功能还有待优化。 Newspaper 的基本用法介绍到这里,更加详细的用法可以参考官方文档:https://newspaper.readthedocs.io。个人感觉其中的智能解析可以用用,不过据我的个人经验,感觉还是很多解析不对或者解析不全的, 以上便是 Readability 和 Newspaper 的介绍。

其他方案

另外除了这两个库其实还有一些比较优秀的算法,由于我们处理的大多为中文文档,所以一些在中文上面的研究是比较有效的,在这里列几个值得借鉴的中文论文供大家参考:

  • 洪鸿辉等,基于文本及符号密度的网页正文提取方法
  • 梁东等,基于支持向量机的网页正文内容提取方法
  • 王卫红等,基于可视块的多记录型复杂网页信息提取算法

今天还看到一位大佬「青南」根据上面第一篇论文所实现的 GeneralNewsExtractor,GitHub 地址为:https://github.com/kingname/GeneralNewsExtractor,经测试准确率还不错,比 Readability 和 Newspaper 的解析效果要好。我也跟作者进行了交流,后续可能还会基于其他的 Feature 或依赖于视觉化的方法进行优化,大家可以关注下,谢谢!

技术杂谈

这两天想必大家应该被一个软件刷屏了,它的名字叫做 Zao,中文音译就叫“造”。它为什么这么火呢?是因为我们可以上传自己的一张照片,他就能把我们的脸替换成一些热门视频的男主或女主的脸,也就是视频换脸。 比如有人尝试了把尼古拉斯赵四的脸换到美国队长的脸上,美队的气质简直就是被垄断了,大家可以扫码看看: 美队变赵四 视频换脸技术大家应该早有耳闻,但这个软件有点意思,它抓住了几个点使得它一炮而红。 第一是这个软件的效果确实不错,我拿自己也做了实验,发现确实它渲染的一些结果几乎毫无违和感,毕竟这个软件核心拼的就是技术。 第二这个软件贴近于日常生活,我们可以把自己的照片上传,让我们真正成为视频里的主角。另外视频选材很有讲究,都是一些剪辑过的明星精彩镜头,这样我们生成的视频镜头会让我们有变成明星的感觉,非常有代入感。

技术实现

作为一名程序员,当然最关心的可能就是它的技术实现了,毋庸置疑它肯定是利用了深度学习的一些技术。我看了一些文章和调研,大体了解了一下,下面稍微分析一下里面用到的一些技术。 整体而言呢,这个过程分为三步,他们分别是:

  • 人脸定位
  • 人脸转换
  • 人脸融合

人脸定位

现在深度学习对于人脸识别和定位的研究技术已经非常成熟和精准了,其核心就是使用了卷积神经网络,即 CNN,不同的模型架构对于识别的准确率有不用的表现。 对于人脸的定位,一般是使用脸部的关键点定位的,这些点叫做 Landmarks。在一张人脸图像上,每张脸的轮廓和五官的位置都会被打上点,比如整个脸部的轮廓用一些点描出来,鼻子、眼睛、唇形同样用一些点描出来。 Facial Feature Detection 一般来说一张脸会用 68 个点来标记出来,每识别的模型接收一张人脸图像,输出这 68 个点的坐标,这样我们就可以实现人脸定位了。 现在现成的模型也很多了,比如 dlib,opencv 等开源工具包可以直接拿来使用了,如果要更精准地话可以使用更复杂的卷积神经网络模型来实现,大家可以了解下相关论文。

人脸生成

有了标记点以后,这个软件就可以把我们的人脸提取出来了,但是这有个问题,我们上传的是一张静态图片,总不能直接生硬地替换进去吧,比如我们上传的是一张正脸照片,那视频里的一些侧脸画面直接贴上那不就没法看了吗? 这时候就要用到另外一个核心技术叫做人脸生成技术,有了它我们就可以对人脸进行生成了,比如根据一张正脸图生成一张侧脸图。目前人脸生成技术主要有两种,有 GAN(生成对抗网络)和 VAE(变分自编码器),下面简单介绍一下它们的原理。 对于 GAN 来说,它叫做生成对抗网络,为什么叫对抗网络呢?是因为模型在训练的过程中一直有两个东西在做对抗,这俩东西分别叫 Generator(生成器)和 Discriminator(判别器)。前者主要负责生成一张人脸,越像越牛逼。后者主要负责判断分辨前者生成的人脸是不是真的,判定越准越牛逼。二者在这个过程中为了变得越来越牛逼,前者就会尽力去生成更像的人脸来欺骗后者,后者也会尽力去判别生成的人脸是不是真的来打击前者。这样二者在不断地训练和对抗过程中,前者生成的结果就会越来越好了。 对于 VAE 呢,它是通过一些无监督学习的方式将人脸信息进行压缩,由编码器把它表示成一个短向量,这些向量里就包含了人脸的基本信息,比如肤色、唇形等信息,这样整个模型就可以学习到人脸的共性。然后,解码器将向量解码,将其转换为某一特定的人脸。这样就等于经过一层中间向量完成了从一张人脸到另一张人脸的转换。

图像融合

最后的阶段就是图像融合了,也就是把生成的新的人脸和原来图像的背景融合,使之不会产生违和感。 在这个软件中,视频是由一帧一帧组成的,那么在转换的时候也需要一帧一帧处理,最后处理完成后再合成整个视频。 以上也就是我所了解到的变脸的一些方法。

安全性

有人说,这个技术不是什么好技术。万一有人拿着我们的照片一变脸,就能够把我们任意的表情和头部动作模拟出来,拿着去做认证,比如刷脸支付什么的咋办,那我们的钱不就被盗刷了吗? 对于这个问题,支付宝官方也做了回应,支付宝称刷脸支付实际上会通过软硬件结合的方式进行检测,其会判断被刷物体是否是照片、视频或者软件模拟的方式生成的,可以有效避免身份冒用情况。其中有一个核心技术就是通过 3D 结构光摄像头来进行信息采集和识别,如果被拍摄物体是平面的,也就是说如果是照片或者视频,是无法通过检测的。 支付宝回应 这时候我自然而然想到,既然用的是 3D 结构光摄像头,那么如果用了 3D 打印技术把一个人的肖像打印出来,或者用一个非常逼真的蜡像来进行刷脸识别,能不能通过呢?我看了一些报道,发现不少案例的确通过了刷脸测试,比如解开了 iPhone 面部识别锁等等。但要通过 3D 打印技术来模拟一个人的肖像成本还是蛮高的,所以基本上也不太会有人来搞这些。 如果对此还心有余悸的话,支付宝还回应称,即便是真的被盗刷了,支付宝也会通过保险公司进行全额赔付。 所以基本上是不用担心其安全性的,尤其是 Zao 这个软件的出现是没有对刷脸支付的风险造成大的影响的,其就是增加了一个活体视频模拟的实现,对刷脸支付的安全性没有出现大的突破性威胁。

隐私性

这个就要好好说一下了,这个软件的出现同时引起了另一个轩然大波,那就是其中的隐私条款。 其隐私条款有一条是这样的:

用户上传发布内容后,意味着同意授予 ZAO 及其关联公司以及 ZAO 用户在“全球范围内完全免费、不可撤销、永久、可转授权和可再许可的权利”,“包括但不限于可以对用户内容进行全部或部分的修改与编辑(如将短视频中的人脸或者声音换成另一个人的人脸或者声音等)以及对修改前后的用户内容进行信息网络传播以及《著作权法》规定的由著作权人享有的全部著作财产权利及邻接权利”。

这条款没人说还真没注意到,因为一般咱用一个软件,一般不会去仔细看它的条款,那么密密麻麻的一坨,有几个人会去仔细看呢?但要不同意,这个软件还没法用,所以用过这个软件的人,这个条款一定是已经同意了。 这条条款其实是很过分的,同意授予 Zao 及其关联公司以及 Zao 用户在“全球范围内完全免费、不可撤销、永久、可转授权和可再许可的权利。注意这里有几个字,完全免费、不可撤销、永久、可转授权、可再许可,这几个词就代表我们已经把我们的肖像权永久授予了 Zao 及其关联公司了,而且不能撤销,账号注销了也不能撤销,也就是以后它们可以有权利永久滥用我们的肖像。更可怕的是,其中还有一个词叫可转授权,那也就是说,Zao 可以对我们的肖像权进行转授权,你懂得,给点钱,啥办不到呢?这就更无法控制了,这可能就意味着,世界上任何一个人可能都能获得我们的肖像权。 所以说,如果你还没用的话,一定要谨慎谨慎再谨慎! 哎,反正我已经同意了,貌似我现在也没什么办法了。

社会影响

这个软件的出现,更深一点想,其实它所隐含的影响还是蛮大的。 有了这个变脸技术,如果有人获得了我们在条款里面所”捐出“的肖像权,拿着我们的照片去生产那种你懂得的影片,把视频里面的男主或者女主换成我们的人脸,然后到处传播,或者以此作为敲诈勒索的工具。即便我们有理,那也说不清了,首先这个条款已经说了它们可以有权利随意使用我们的肖像,所以告侵犯肖像权已经行不通了,而且即使我们有证据证明这是假的,但这种视频的传播也一定会带来非常大的影响。 按照现在大众们的观念,比如说一张图,我们如果不信的话可以说它是 P 的,但如果换做是视频的话,很多人可能就会相信了,因为很多人不知道视频中的肖像也可以伪造得这么真了,毕竟很多人并不知道这种技术。因此,有了这种技术的出现,以后视频类的证据,可能也不可信了。因此这个软件的出现,可以说从另一个侧面昭示,以后视频也不能作为犯案的证据和验证人的真伪的依据了。 所以以后可能是这样子的:

  • 坐在电脑面前的网络女主播,即便不开美颜和滤镜,你所看到的她也不是真的她了。
  • 你要给人打个钱,说开个视频吧,我看看是不是真的你,即便看到的是他,你也不能信了。
  • 有人要 Qiao Zha 你,把你的人脸换成 Zuo An 分子的脸,你到哪里说理去?
  • 某一天,你作为男女主角,出现在了 P 站和 91….

我一开始想的还没这么深,边想边写,写到这,我自己都开始后怕了… 怎么甚至感觉,以后的社会可能会乱套了呢?这可能就是 AI 发展的一个隐患吧。 所以写到最后,虽然这个软件很有意思,但还是劝大家还没有用的就不要用了吧,真的很可怕。同时我也不知道这个软件这样的条款和做法会不会有什么问题,但还是希望能引起有关部门的注意。 以后,也希望大家也可以在使用软件的时候,要更加谨慎和小心,有条款就稍微看一看,尤其是对于这种和用户隐私相关的软件,要更加心存戒备。

参考文章

本文参考来源:

  • 机器之心:刷屏的 ZAO 换脸 APP 你玩了吗?
  • 支付宝推出的刷脸支付是基于“活体检测”技术做支撑

技术杂谈

开发者如何学好 MongoDB

作为一名研发,数据库是或多或少都会接触到的技术。MongoDB 是热门的 NoSQL 之一,我们怎样才能学好 MongoDB 呢? 本篇文章,我们将从以下几方面讨论这个话题:

  1. MongoDB 是什么
  2. 我如何确定我需要学习 MongoDB
  3. 开发者应该掌握 MongoDB 的哪些知识
  4. 学习的选择和困境

我们先来了解一下,MongoDB 为何物。 NoSQL 泛指非关系型数据库,该词是关系型数据库(即 SQL)的相对称呼。MongoDB 是非关系型数据库(NoSQL)中较为人熟知的一种。它拥有很多优秀特性,例如高性能、高可用、支持丰富的查询语句、无需预定义数据模型和水平可伸缩等,适合存储结构化、半结构化的文档和特定格式的文档,这些特性使它受到众多开发者的青睐。 我们通过几个例子来看看 MySQL 与 MongoDB 的差异。 与 MySQL 数据库不同的是,MongoDB 不需要预先定义表和字段,这正是它灵活性的体现。MongoDB 可以拥有多个数据库,每个数据库可以拥有多个集合,每个集合可以存储多份文档,这种关系与 SQL 数据库中的“数据库、表、数据”相当。下图描述了 MongoDB 中数据库、集合和文档的关系: 数据库 fotoo 中有两个集合,它们分别是 playerbooks。每个集合中都包含了许多文档,例如集合 books 中关于书籍《红楼梦》的文档,集合 player 中关于球员 James 的文档。 在查询方面,一个简单的 MySQL 查询语句为 SELECT * FROM tablename,对应的 MongoDB 查询语句为 db.tablename.find()。在面对多步骤的查询条件时,MongoDB 更游刃有余。例如: “统计数据库 articscore 大于 70 且小于 90 的文档数量” 这样的需求,用 MongoDB 的聚合操作就可以轻松完成,对应示例如下:

1
2
3
4
\> db.artic.aggregate([
... {$match: {score: {$gt: 70, $lt: 90}}},
... {$group: {_id: null, number: {$sum: 1}}}
... ])

这个例子或许简单了些,在 MySQL 中我们可以用 countwhere 完成,但如果复杂度再提高四五个等级呢?例如在此基础上增加对某个字段的运算、替换、排序、分组计数、增删字段,用 MySQL 来实现就会很头疼,而 MongoDB 的聚合可以让我们轻松地完成这类复杂需求。

我如何确定我需要学习 MongoDB

MongoDB 是近些年涌现的几十种 NoSQL 中第一梯队的成员,另外一个为人熟知的是 Redis。你可能会有”我如何确定我需要学习 MongoDB 呢?“ 这样的疑问,面对这个问题,我们可以通过 MongoDB 的特点和应用场景着手。

  • MongoDB 适合存储结构确定或不确定的文档。例如爬虫爬取的信息常缺失字段的情况或字段参差不齐的情况;
  • 对数据库可用性要求较高的情况。MySQL 这类数据库要做到负载均衡、自动容灾和数据同步需要借助外部工具,而 MongoDB 的复制集可以让我们轻松完成这一系列的工作。相对接借助第三方工具来说,复制集的稳定性更高。
  • 分库分表是 WEB 开发中常用到的数据库优化手段,MySQL 的分库分表要考虑的问题非常多,例如字段冗余、数据组装跨节点分页、排序和数据迁移等,而 MongoDB 的分片可以让我们轻松完成“分库分表”的工作。MongoDB 的分片机制使我们不必将心思放在由“分库分表”带来的问题,而是专注于具体需求。
  • 同样的,MySQL 的权限控制、定义数据模型、数据库备份和恢复等功能在 MongoDB 上也有。
  • MongoDB 中支持地理位置的存储和查询,这意味着 MongoDB 可以用于共享单车、共享雨伞、汽车定位等业务中。

我们常用的关系型数据库无法满足 WEB2.0 时代的需求,在实际应用中暴露了很多难以克服的问题。NoSQL 的产生就是为了解决例如海量数据的存储弹性可伸缩灵活性等方面的挑战,所以作为一名合格的开发者,应该抽空学习 SQL 以外的数据库知识。

开发者应该掌握 MongoDB 的哪些知识

学习前,我们需要明白自身定位:专业 DBA 或者日常开发使用。MongoDB 有完善的培训体系和对应的认证考试,对于希望成为专业 DBA 的朋友我建议到 MongoDB 官方网站了解。而对于仅需要满足日常开发需求的朋友,我建议学习的内容如下:

  • MongoDB 在各个平台的安装方法
  • MongoDB 数据库和集合的基本操作
  • MongoDB 文档 CRUD 操作,包括能够丰富 CRUD 的投影和修饰符等
  • MongoDB 流式聚合操作,这能够在数据库层面轻松完成复杂数据的处理,而不是用编程语言来处理
  • MongoDB 的数据模型,虽然它可以存储不规则的文档,但有些情况下定义数据模型可以提高查询效率

当然,除了这些基本操作之外我们还可以学习更多的知识提高个人竞争力,这些知识是:

  • MongoDB 执行计划和索引,执行计划可以让我们清楚的了解到查询语句的效率,而索引则是优化查询效率的常用手段
  • MongoDB 的复制集,这是提高 MongoDB 可用性,保证数据服务不停机的最佳手段
  • MongoDB 的分片,分片能够在数据量变得庞大之后保证效率
  • MongoDB 的事物,如果你将 MongoDB 用于 WEB 网站,那么事物是你必须学习的知识
  • MongoDB 数据库备份和还原,有了复制集后,备份就显得不是那么重要了,但并不是没有这个需求。而且 MongoDB 的备份可以精细到文档,这就非常有意义了。

学习的选择和困境

有一定工作经验的开发者,大多数情况下都会选择自学。有些在网上搜索对应的文章,有些则直接翻阅官方文档。我推荐的方式是翻阅官方文档,在遇到难以理解的观点时通过搜索引擎查找网友分享的文章。 自学的优点很多,缺点也很明显。例如:

  • 断断续续的学习,难以保持专注导致知识吸收不好
  • 耗费时间很长,虽然知道应该学习哪些方面的知识,但文档并不是按你所想而规划的,所以翻阅文档要费很多功夫
  • 知识不成体系,东看看西看看,没有归纳容易忘记
  • 学习就需要记笔记,这又是一件很费时间的事情
  • 官方文档有些观点难以理解,卡在半路很难受
  • 零星学了一两个月,也不确定学会了没有,内心毫无把握

如果不自学,就得找一些成体系的课程来帮助自己快速进步,少走弯路。知识付费时代,在条件允许的情况下适当地投入也是很好的选择。但面对动辄几百块的视频课程,不少开发者还是感觉略有压力,毕竟我们搬砖的经济压力也非常大。培训班就更不用说了,很少有专业教授单个数据库知识的,而且费用比视频课程更贵。 考虑到这些问题,这里推荐韦世东的 GitChat 文章 《超高性价比的 MongoDB 零基础快速入门实战教程》,这也是一个收费教程,但它售价不到 10 块钱。文章作者韦世东是:图灵签约作者、电子工业出版社约稿作者,华为云认证云享专家、掘金社区优秀作者、GitChat 认证作者,开源项目 aiowebsocket 作者。所以在文章质量上,大家可以放心。 这篇文章的内容几乎囊括了上面我们提到的所有知识点,看完这篇仅 5 万词的文章,你将收获:

  • 文档的 CRUD 操作和 Cursor 对象
  • 掌握流式聚合操作,轻松面对任何数据处理需求
  • 了解 MongoDB 的查询效率和优化
  • 如何提高 MongoDB 的可用性
  • 如何应对数据服务故障
  • 理解 MongoDB 的访问控制
  • 学会用数据模型降低数据冗余,提高效率
  • 掌握 mongodump 数据备份与还原方法

这样就可以胜任日常开发中对数据库操作能力的要求了。这篇文章适合对 MongoDB 感兴趣的零基础开发者或者有一定基础,想要继续巩固和加深学习的开发者。文章篇幅很长,内容详尽,不乏优质配图,例如描述复制集节点关系的图: 描述节主点掉线,重新选举主节点的图 如果你觉得有学习 MongoDB 的需要,且这篇文章规划的内容是你想要的内容,那么请长按下方图片识别二维码,前往订阅文章吧!

Python

在做程序开发的时候,我们经常会用到一些测试数据,相信大多数同学是这么来造测试数据的:

1
2
3
4
5
6
7
8
test1
test01
test02
测试1
测试2
测试数据1
这是一段测试文本
这是一段很长很长很长的测试文本...

中枪的请举手。 不仅要自己手动敲这些测试数据,还敲的这么假。那有啥办法呢?难不成有什么东西能自动给我造点以假乱真的数据啊?你别说,还真有! 在 Python 中有个神库,叫做 Faker,它可以自动帮我们来生成各种各样的看起来很真的”假“数据,让我们来看看吧!

安装

首先让我们来看看这个库的安装方法,实际上装起来非常简单,使用 pip 安装即可,Python3 版本的安装命令如下:

1
pip3 install faker

安装好了之后,我们使用最简单的例子来生成几个假数据试试:

1
2
3
4
5
6
from faker import Faker

faker = Faker()
print('name:', faker.name())
print('address:', faker.address())
print('text:', faker.text())

首先我们从 faker 这个包里面导入一个 Faker 类,然后将其实例化为 faker 对象,依次调用它的 name、address、text 方法,看下运行效果:

1
2
3
4
5
6
name: Nicholas Wilson
address: 70561 Simmons Road Apt. 893
Lake Raymondville, HI 35240
text: Both begin bring federal space.
Official start idea specific. Able under young fire.
Who show line traditional easy people. Until economic lead event case. Technology college his director style.

看到这里给我们生成了看起来很真的英文姓名、地址、长文本。 但我们是中国人,我们肯定想要生成中文的吧,不用担心,这个库对非常多的语言都有支持,当然也包括中文了,具体的支持的语言列表可以见:https://faker.readthedocs.io/en/master/locales.html。 这里几个比较常见的语言代号列一下:

  • 简体中文:zh_CN
  • 繁体中文:zh_TW
  • 美国英文:en_US
  • 英国英文:en_GB
  • 德文:de_DE
  • 日文:ja_JP
  • 韩文:ko_KR
  • 法文:fr_FR

那么如果要生成中文,只需要在 Faker 类的第一个参数传入对应的语言代号即可,例如简体中文就传入 zh_CN,所以上面的代码改写如下:

1
2
3
4
5
6
from faker import Faker

faker = Faker('zh_CN')
print('name:', faker.name())
print('address:', faker.address())
print('text:', faker.text())

运行结果如下:

1
2
3
4
5
6
7
name: 何琳
address: 宁夏回族自治区六盘水县南溪北镇街f座 912311
text: 经营软件积分开始次数专业.美国留言一种管理人民解决两个.支持只有地方一切.
文化目前东西的是不过所以.系统觉得这种为什一下他们.时候以及这样继续是一状态威望.
网站密码情况.问题一点那个还是.其实过程详细.
中国历史环境电话规定.经验上海控制不要生活.朋友运行项目我们.
以后今天那些使用免费国家加入但是.内容简介空间次数最大一个.日期通过得到日本北京.

可以看到一段中文的姓名、地址、长文本便生成了。看起来地址是省份、地级市、县级市、街道是随机组合的,文本也是一些随机的词组合而成的,但其实这样已经比文章一开头列的测试数据强太多了。 上面的代码每次运行得到的结果都是不同的,因为生成的结果都是随机组合而成的。

Provider

接下来让我们详细看下 faker 可以都生成什么类型的数据,具体的可用 API 可以看 https://faker.readthedocs.io/en/master/locales/zh_CN.html,这里面列出来了可用的所有方法。 但打开之后可以发现,这里面多了一个 Provider 对象,那么这个 Provider 是怎么一回事呢? 实际上这个 faker 库在设计上,为了解耦,将 Provider 对象做成了 Faker 对象的”插件“。Faker 可以添加一个个 Provider 对象,Provider 对象为 Faker 对象提供了生成某项数据的核心实现。就相当于 Faker 对象是一个生成器,它的生成功能依赖于什么呢?依赖于 Provider,是 Provider 提供给了 Faker 对象生成某项数据的能力。 正是因为 Faker 对象内置了一些 Provider 对象,Faker 对象才可以生成刚才所要求的姓名、地址和文本。 那么这时候我们肯定就很好奇了,既然 Faker 对象有生成数据的能力,那么它一定内置了一些默认的 Provider 对象,下面我们来打印看一下:

1
2
3
4
from faker import Faker

faker = Faker('zh_CN')
print(faker.providers)

运行结果如下:

1
[<faker.providers.user_agent.Provider object at 0x10249de48>, <faker.providers.ssn.zh_CN.Provider object at 0x10249dc18>, <faker.providers.python.Provider object at 0x10249dd68>, <faker.providers.profile.Provider object at 0x10249dcc0>, <faker.providers.phone_number.zh_CN.Provider object at 0x10249dc88>, <faker.providers.person.zh_CN.Provider object at 0x10249de80>, <faker.providers.misc.Provider object at 0x10249df60>, <faker.providers.lorem.zh_CN.Provider object at 0x10249dc50>, <faker.providers.job.zh_CN.Provider object at 0x10249de10>, <faker.providers.isbn.Provider object at 0x10249c6d8>, <faker.providers.internet.zh_CN.Provider object at 0x10249c828>, <faker.providers.geo.en_US.Provider object at 0x102484748>, <faker.providers.file.Provider object at 0x102484828>, <faker.providers.date_time.en_US.Provider object at 0x1023789e8>, <faker.providers.currency.Provider object at 0x102484780>, <faker.providers.credit_card.Provider object at 0x1024845f8>, <faker.providers.company.zh_CN.Provider object at 0x102499ef0>, <faker.providers.color.en_US.Provider object at 0x1023532e8>, <faker.providers.barcode.Provider object at 0x101cb6d30>, <faker.providers.bank.en_GB.Provider object at 0x102378f98>, <faker.providers.automotive.en_US.Provider object at 0x1017a5c50>, <faker.providers.address.zh_CN.Provider object at 0x101787c18>]

还真不少,通过名字可以看到有 user_agent、phone_number、isbn、credit_card 等 Provider,其中具有语言差异化的 Provider 还单独区分了语言,比如 phone_number 代表电话号码,这个不同语言的不同,所以这里就又分了一层 zh_CN,作了语言的区分。 这样一来,通用的 Provider 就直接处在某个 Provider 类别的模块中,具有语言差异的 Provider 就又根据不同的语言进一步划分了模块,设计上非常科学,易扩展又不冗余。 知道了 Faker 具有这么多 Provider 之后,我们来看看刚才调用的 name、address 等方法又和 Provider 有什么关系呢? 我们将 name、address、text 等方法打印一下看看:

1
2
3
4
5
6
from faker import Faker

faker = Faker('zh_CN')
print('name:', faker.name)
print('address:', faker.address)
print('text:', faker.text)

注意这里没有调用,而是直接打印了这三个方法,这样可以直接输出方法的对象形式的描述,结果如下:

1
2
3
name: <bound method Provider.name of <faker.providers.person.zh_CN.Provider object at 0x10f6dea58>>
address: <bound method Provider.address of <faker.providers.address.zh_CN.Provider object at 0x10e9e6cf8>>
text: <bound method Provider.text of <faker.providers.lorem.zh_CN.Provider object at 0x10f6dfda0>>

恍然大悟,原来我们调用的方法就是 Faker 对象调用的 Provider 里面的对应方法,比如 name 就是 faker.providers.person.zhCN.Provider 里面的 name 方法,二者是一致的,我们扒一扒源码验证下,源码在:[[https://github.com/joke2k/faker/blob/master/faker/providers/person/__init](https://github.com/joke2k/faker/blob/master/faker/providers/person/__init_)_.py]([https://github.com/joke2k/faker/blob/master/faker/providers/person/__init__.py),果不其然,里面定义了](https://github.com/joke2k/faker/blob/master/faker/providers/person/__init__.py),果不其然,里面定义了) name 方法,然后 Faker 动态地将这个方法引入进来了,就可以使用了。

方法列举

既然有这么多 Provider,下面我们再详细地看看还有哪些常用的方法吧,下面进行一部分简单的梳理,参考来源文档地址为:https://faker.readthedocs.io/en/master/providers.html

Address

Address,用于生成一些和地址相关的数据,如地址、城市、邮政编码、街道等内容, 用法如下:

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
faker.address()
# '新疆维吾尔自治区杰县南湖武汉街D座 253105'
faker.building_number()
# 'B座'
faker.city()
# '璐县'
faker.city_name()
# '贵阳'
faker.city_suffix()
# '县'
faker.country()
# '阿拉斯加'
faker.country_code(representation="alpha-2")
# 'CR'
faker.district()
# '西峰'
faker.postcode()
# '726749'
faker.province()
# '福建省'
faker.street_address()
# '余路N座'
faker.street_name()
# '李路'
faker.street_suffix()
# '路'

Color

Color,用于生成和颜色相关的数据,如 HEX、RGB、RGBA 等格式的颜色,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
faker.color_name()
# 'DarkKhaki'
faker.hex_color()
# '#97d14e'
faker.rgb_color()
# '107,179,51'
faker.rgb_css_color()
# 'rgb(20,46,70)'
faker.safe_color_name()
# 'navy'
faker.safe_hex_color()
# '#dd2200'

Company

Company,用于生成公司相关数据,如公司名、公司前缀、公司后缀等内容,用法如下:

1
2
3
4
5
6
7
8
9
10
faker.bs()
# 'grow rich initiatives'
faker.catch_phrase()
# 'Self-enabling encompassing function'
faker.company()
# '恒聪百汇网络有限公司'
faker.company_prefix()
# '晖来计算机'
faker.company_suffix()
# '信息有限公司'

Credit Card

Credit Card,用于生成信用卡相关数据,如过期时间、银行卡号、安全码等内容,用法如下:

1
2
3
4
5
6
7
8
9
10
faker.credit_card_expire(start="now", end="+10y", date_format="%m/%y")
# '08/20'
faker.credit_card_full(card_type=None)
# 'Mastercardn玉兰 范n5183689713096897 01/25nCVV: 012n'
faker.credit_card_number(card_type=None)
# '4009911097184929918'
faker.credit_card_provider(card_type=None)
# 'JCB 15 digit'
faker.credit_card_security_code(card_type=None)
# '259'

Date Time

Date Time,用于生成时间相关数据,如年份、月份、星期、出生日期等内容,可以返回 datetime 类型的数据,用法如下:

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
faker.am_pm()
# 'AM'
faker.century()
# 'X'
faker.date(pattern="%Y-%m-%d", end_datetime=None)
# '1997-06-16'
faker.date_between(start_date="-30y", end_date="today")
# datetime.date(2000, 8, 30)
faker.date_between_dates(date_start=None, date_end=None)
# datetime.date(2019, 7, 30)
faker.date_object(end_datetime=None)
# datetime.date(1978, 3, 12)
faker.date_of_birth(tzinfo=None, minimum_age=0, maximum_age=115)
# datetime.date(2012, 6, 3)
faker.date_this_century(before_today=True, after_today=False)
# datetime.date(2011, 6, 12)
faker.date_this_decade(before_today=True, after_today=False)
# datetime.date(2011, 8, 22)
faker.date_this_month(before_today=True, after_today=False)
# datetime.date(2019, 7, 25)
faker.date_this_year(before_today=True, after_today=False)
# datetime.date(2019, 7, 22)
faker.date_time(tzinfo=None, end_datetime=None)
# datetime.datetime(2018, 8, 11, 22, 3, 34)
faker.date_time_ad(tzinfo=None, end_datetime=None, start_datetime=None)
# datetime.datetime(1566, 8, 26, 16, 25, 30)
faker.date_time_between(start_date="-30y", end_date="now", tzinfo=None)
# datetime.datetime(2015, 1, 31, 4, 14, 10)
faker.date_time_between_dates(datetime_start=None, datetime_end=None, tzinfo=None)
# datetime.datetime(2019, 7, 30, 17, 51, 44)
faker.date_time_this_century(before_now=True, after_now=False, tzinfo=None)
# datetime.datetime(2002, 9, 25, 23, 59, 49)
faker.date_time_this_decade(before_now=True, after_now=False, tzinfo=None)
# datetime.datetime(2010, 5, 25, 20, 20, 52)
faker.date_time_this_month(before_now=True, after_now=False, tzinfo=None)
# datetime.datetime(2019, 7, 19, 18, 4, 6)
faker.date_time_this_year(before_now=True, after_now=False, tzinfo=None)
# datetime.datetime(2019, 3, 15, 11, 4, 18)
faker.day_of_month()
# '04'
faker.day_of_week()
# 'Monday'
faker.future_date(end_date="+30d", tzinfo=None)
# datetime.date(2019, 8, 12)
faker.future_datetime(end_date="+30d", tzinfo=None)
# datetime.datetime(2019, 8, 24, 2, 59, 4)
faker.iso8601(tzinfo=None, end_datetime=None)
# '1987-07-01T18:33:56'
faker.month()
# '11'
faker.month_name()
# 'August'
faker.past_date(start_date="-30d", tzinfo=None)
# datetime.date(2019, 7, 25)
faker.past_datetime(start_date="-30d", tzinfo=None)
# datetime.datetime(2019, 7, 18, 22, 46, 51)
faker.time(pattern="%H:%M:%S", end_datetime=None)
# '16:22:30'
faker.time_delta(end_datetime=None)
# datetime.timedelta(0)
faker.time_object(end_datetime=None)
# datetime.time(22, 12, 15)
faker.time_series(start_date="-30d", end_date="now", precision=None, distrib=None, tzinfo=None)
# <generator object Provider.time_series at 0x7fcbce0604f8>
faker.timezone()
# 'Indian/Comoro'
faker.unix_time(end_datetime=None, start_datetime=None)
# 1182857626
faker.year()
# '1970'

File

File,用于生成文件和文件路径相关的数据,包括文件扩展名、文件路径、MIME_TYPE、磁盘分区等内容,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
faker.file_extension(category=None)
# 'flac'
faker.file_name(category=None, extension=None)
# '然后.numbers'
faker.file_path(depth=1, category=None, extension=None)
# '/关系/科技.mov'
faker.mime_type(category=None)
# 'video/ogg'
faker.unix_device(prefix=None)
# '/dev/sdd'
faker.unix_partition(prefix=None)
# '/dev/xvds3'

Geo

Geo,用于生成和地理位置相关的数据,包括经纬度,时区等等信息,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
faker.coordinate(center=None, radius=0.001)
# Decimal('-114.420686')
faker.latitude()
# Decimal('-9.772541')
faker.latlng()
# (Decimal('-27.0730915'), Decimal('-5.919460'))
faker.local_latlng(country_code="US", coords_only=False)
# ('41.47892', '-87.45476', 'Schererville', 'US', 'America/Chicago')
faker.location_on_land(coords_only=False)
# ('12.74482', '4.52514', 'Argungu', 'NG', 'Africa/Lagos')
faker.longitude()
# Decimal('40.885895')

Internet

Internet,用于生成和互联网相关的数据,包括随机电子邮箱、域名、IP 地址、URL、用户名、后缀名等内容,用法如下:

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
faker.ascii_company_email(*args, **kwargs)
# 'xuna@xiaqian.cn'
faker.ascii_email(*args, **kwargs)
# 'min59@60.cn'
faker.ascii_free_email(*args, **kwargs)
# 'min75@gmail.com'
faker.ascii_safe_email(*args, **kwargs)
# 'cliu@example.com'
faker.company_email(*args, **kwargs)
# 'ilong@99.cn'
faker.domain_name(levels=1)
# 'xiulan.cn'
faker.domain_word(*args, **kwargs)
# 'luo'
faker.email(*args, **kwargs)
# 'maoxiulan@hotmail.com'
faker.free_email(*args, **kwargs)
# 'yanshen@gmail.com'
faker.free_email_domain(*args, **kwargs)
# 'yahoo.com'
faker.hostname(*args, **kwargs)
# 'lt-18.pan.cn'
faker.image_url(width=None, height=None)
# 'https://placekitten.com/51/201'
faker.ipv4(network=False, address_class=None, private=None)
# '192.233.68.5'
faker.ipv4_network_class()
# 'a'
faker.ipv4_private(network=False, address_class=None)
# '10.9.97.93'
faker.ipv4_public(network=False, address_class=None)
# '192.51.22.7'
faker.ipv6(network=False)
# 'de57:9c6f:a38c:9864:10ec:6442:775d:5f02'
faker.mac_address()
# '99:80:5c:ab:8c:a9'
faker.safe_email(*args, **kwargs)
# 'tangjuan@example.net'
faker.slug(*args, **kwargs)
# ''
faker.tld()
# 'cn'
faker.uri()
# 'http://fangfan.org/app/tag/post/'
faker.uri_extension()
# '.php'
faker.uri_page()
# 'about'
faker.uri_path(deep=None)
# 'app'
faker.url(schemes=None)
# 'http://mingli.cn/'
faker.user_name(*args, **kwargs)
# 'jie54'

Job

Job,用于生成和职业相关的数据,用法如下:

1
2
faker.job()
# '烫工'

Lorem

Lorem,用于生成一些假文字数据,包括句子、自然段、长文本、关键词等,另外可以传入不同的参数来控制生成的长度,用法如下:

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
faker.paragraph(nb_sentences=3, variable_nb_sentences=True, ext_word_list=None)
# '包括的是报告那些一点.图片地址基本全部.'
faker.paragraphs(nb=3, ext_word_list=None)
# [ '计划规定这样所以组织商品其中.参加成为不同发表地区.精华科技谢谢大家需要.一下手机上海中文工程.',
# '非常相关是一就是一个一种文章发生.增加那些以及之后以下你的.',
# '学生应该出来分析增加关系组织.评论来源朋友注册应该需要单位.感觉最后无法发现选择人民.']
faker.sentence(nb_words=6, variable_nb_words=True, ext_word_list=None)
# '介绍结果自己解决处理.'
faker.sentences(nb=3, ext_word_list=None)
# ['查看其实一次学习登录浏览是一他们.', '而且资源的人事情.', '科技价格免费大学教育.']
faker.text(max_nb_chars=200, ext_word_list=None)
# ('只是当前国内中文所以.威望系统在线虽然.n'
# '图片人民非常合作这种谢谢更新.名称详细直接社会一直首页完全.n'
# '重要更多只要市场.必须只是学生音乐.系统美国类别这些一切环境.n'
# '但是的话人民美国关于.n'
# '情况专业国际看到研究.音乐环境市场搜索发现.n'
# '工具还是到了今天位置人民.留言作者品牌工程项目必须.上海精华现在我们新闻应该关系.n'
# '更新经济能力全部资源如果.手机能够登录国内.')
faker.texts(nb_texts=3, max_nb_chars=200, ext_word_list=None)
# [ '成功可能推荐你的行业.地区而且推荐.n'
# '网络不断是一主要必须.开始安全服务.n'
# '应该网上通过以后通过大学.管理要求有关国际阅读当前.为了应该结果点击公司开始怎么.n'
# '成功一次最大生产网站.这种加入她的地址有限.n'
# '根据新闻汽车起来非常主题显示必须.有些建设来自作者电话支持.n'
# '只是资源还是由于经济事情喜欢.为什中文大小得到服务.网络密码是否免费参加一次社区欢迎.',
# '部门活动技术.商品影响发生行业密码完成.就是部门结果资料学习当然.或者帮助城市要求首页市场教育你们.n'
# '专业完全分析处理城市大学什么.n'
# '文件非常国际全部起来积分公司.资料的是电影没有.这是本站需要.n'
# '合作重要没有现在市场开发空间.您的会员推荐成功教育进行中国.n'
# '文件不是如果评论.因为经验设备规定.n'
# '加入一起影响网上大家运行在线如果.工程企业这种以后.',
# '空间市场出现必须基本电话.显示一个标准其他设计作品.工程不断新闻问题更多更新这么.n'
# '一起简介网上内容不会.任何知道各种两个.类别事情经营那么投资市场.n'
# '那些使用介绍公司朋友人民你们浏览.应该表示一点一般说明主要谢谢.电话回复起来经验一个来源加入.n'
# '地区法律其他表示虽然.参加社会喜欢有限论坛一般发布.类别目前文化可以.n'
# '报告质量工作主要.企业发布完全.得到名称作者等级两个论坛只要电话.']
faker.word(ext_word_list=None)
# '注意'
faker.words(nb=3, ext_word_list=None, unique=False)
# ['责任', '组织', '以后']

在这里每个方法的参数是不同的,具体的参数解释可以见源代码每个方法的注释:https://github.com/joke2k/faker/blob/master/faker/providers/lorem/init.py

Misc

Misc,用于生成生成一些混淆数据,比如密码、sha1、sha256、md5 等加密后的内容,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
faker.boolean(chance_of_getting_true=50)
# True
faker.md5(raw_output=False)
# '3166fa26ffd3f2a33e020dfe11191ac6'
faker.null_boolean()
# False
faker.password(length=10, special_chars=True, digits=True, upper_case=True, lower_case=True)
# 'W7Ln8La@%O'
faker.sha1(raw_output=False)
# 'c8301a2a79445439ee5287f38053e4b3a05eac79'
faker.sha256(raw_output=False)
# '1e909d331e20cf241aaa2da894deae5a3a75e5cdc35c053422d9b8e7ccfa0402'
faker.uuid4(cast_to=<class 'str'>)
# '6e6fe387-6877-48d9-94ea-4263c4c71aa5'

Person

Person,用于生成和人名相关的数据,包括姓氏、名字、全名、英文名等内容,还能区分男女名字,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
faker.first_name()
# '颖'
faker.first_name_female()
# '芳'
faker.first_name_male()
# '利'
faker.first_romanized_name()
# 'Jing'
faker.last_name()
# '温'
faker.last_name_female()
# '寇'
faker.last_name_male()
# '陈'
faker.last_romanized_name()
# 'Lei'
faker.name()
# '黄明'
faker.name_female()
# '张凯'
faker.name_male()
# '黄鹏'

User-Agent

User-Agent,用于生成和浏览器 User-Agent 相关的内容,可以定制各种浏览器,还可以传入版本信息来控制生成的内容,用法如下:

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
faker.chrome(version_from=13, version_to=63, build_from=800, build_to=899)
# ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/5332 (KHTML, like Gecko) '
# 'Chrome/40.0.837.0 Safari/5332')
faker.firefox()
# ('Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_8_9; rv:1.9.4.20) '
# 'Gecko/2019-05-02 05:58:44 Firefox/3.6.19')
faker.internet_explorer()
# 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/3.0)'
faker.linux_platform_token()
# 'X11; Linux i686'
faker.linux_processor()
# 'x86_64'
faker.mac_platform_token()
# 'Macintosh; U; PPC Mac OS X 10_12_5'
faker.mac_processor()
# 'U; Intel'
faker.opera()
# 'Opera/9.77.(Windows NT 4.0; vi-VN) Presto/2.9.182 Version/11.00'
faker.safari()
# ('Mozilla/5.0 (Macintosh; PPC Mac OS X 10_7_1 rv:5.0; or-IN) '
# 'AppleWebKit/535.9.4 (KHTML, like Gecko) Version/5.0.2 Safari/535.9.4')
faker.user_agent()
# 'Opera/8.69.(X11; Linux i686; ml-IN) Presto/2.9.170 Version/11.00'
faker.windows_platform_token()
# 'Windows NT 6.1'

以上仅仅列了一部分,还有更多的功能大家可以查看官方文档的内容,链接为:https://faker.readthedocs.io/en/master/locales/zh_CN.html

其他 Provider

另外还有一些社区贡献的 Provider,如 WiFi、微服务相关的,大家可以查看文档的说明,另外需要额外安装这些扩展包并自行添加 Provider,文档见:https://faker.readthedocs.io/en/master/communityproviders.html。 添加 Provider 需要调用 add_provider 方法,用法示例如下:

1
2
3
4
5
6
from faker import Faker
from faker.providers import internet

faker = Faker()
faker.add_provider(internet)
print(faker.ipv4_private())

还有更多的内容大家可以参考官方文档,链接:https://faker.readthedocs.io/

Python

实例引入

我们知道 Python 是一种动态语言,在声明一个变量时我们不需要显式地声明它的类型,例如下面的例子:

1
2
a = 2
print('1 + a =', 1 + a)

运行结果:

1
1 + a = 3

这里我们首先声明了一个变量 a,并将其赋值为了 2,然后将最后的结果打印出来,程序输出来了正确的结果。但在这个过程中,我们没有声明它到底是什么类型。 但如果这时候我们将 a 变成一个字符串类型,结果会是怎样的呢?改写如下:

1
2
a = '2'
print('1 + a =', 1 + a)

运行结果:

1
TypeError: unsupported operand type(s) for +: 'int' and 'str'

直接报错了,错误原因是我们进行了字符串类型的变量和数值类型变量的加和,两种数据类型不同,是无法进行相加的。 如果我们将上面的语句改写成一个方法定义:

1
2
def add(a):
return a + 1

这里定义了一个方法,传入一个参数,然后将其加 1 并返回。 如果这时候如果用下面的方式调用,传入的参数是一个数值类型:

1
add(2)

则可以正常输出结果 3。但如果我们传入的参数并不是我们期望的类型,比如传入一个字符类型,那么就会同样报刚才类似的错误。 但又由于 Python 的特性,很多情况下我们并不用去声明它的类型,因此从方法定义上面来看,我们实际上是不知道一个方法的参数到底应该传入什么类型的。 这样其实就造成了很多不方便的地方,在某些情况下一些复杂的方法,如果不借助于一些额外的说明,我们是不知道参数到底是什么类型的。 因此,Python 中的类型注解就显得比较重要了。

类型注解

在 Python 3.5 中,Python PEP 484 引入了类型注解(type hints),在 Python 3.6 中,PEP 526 又进一步引入了变量注解(Variable Annotations),所以上面的代码我们改写成如下写法:

1
2
3
4
5
a: int = 2
print('5 + a =', 5 + a)

def add(a: int) -> int:
return a + 1

具体的语法是可以归纳为两点:

  • 在声明变量时,变量的后面可以加一个冒号,后面再写上变量的类型,如 int、list 等等。
  • 在声明方法返回值的时候,可以在方法的后面加一个箭头,后面加上返回值的类型,如 int、list 等等。

PEP 8 中,具体的格式是这样规定的:

  • 在声明变量类型时,变量后方紧跟一个冒号,冒号后面跟一个空格,再跟上变量的类型。
  • 在声明方法返回值的时候,箭头左边是方法定义,箭头右边是返回值的类型,箭头左右两边都要留有空格。

有了这样的声明,以后我们如果看到这个方法的定义,我们就知道传入的参数类型了,如调用 add 方法的时候,我们就知道传入的需要是一个数值类型的变量,而不是字符串类型,非常直观。 但值得注意的是,这种类型和变量注解实际上只是一种类型提示,对运行实际上是没有影响的,比如调用 add 方法的时候,我们传入的不是 int 类型,而是一个 float 类型,它也不会报错,也不会对参数进行类型转换,如:

1
add(1.5)

我们传入的是一个 float 类型的数值 1.5,看下运行结果:

1
2.5

可以看到,运行结果正常输出,而且 1.5 并没有经过强制类型转换变成 1,否则结果会变成 2。 因此,类型和变量注解只是提供了一种提示,对于运行实际上没有任何影响。 不过有了类型注解,一些 IDE 是可以识别出来并提示的,比如 PyCharm 就可以识别出来在调用某个方法的时候参数类型不一致,会提示 WARNING。 比如上面的调用,如果在 PyCharm 中,就会有如下提示内容:

1
2
Expected type 'int', got 'float' instead
This inspection detects type errors in function call expressions. Due to dynamic dispatch and duck typing, this is possible in a limited but useful number of cases. Types of function parameters can be specified in docstrings or in Python 3 function annotations.

另外也有一些库是支持类型检查的,比如 mypy,安装之后,利用 mypy 即可检查出 Python 脚本中不符合类型注解的调用情况。 上面只是用一个简单的 int 类型做了实例,下面我们再看下一些相对复杂的数据结构,例如列表、元组、字典等类型怎么样来声明。 可想而知了,列表用 list 表示,元组用 tuple 表示,字典用 dict 来表示,那么很自然地,在声明的时候我们就很自然地写成这样了:

1
2
3
names: list = ['Germey', 'Guido']
version: tuple = (3, 7, 4)
operations: dict = {'show': False, 'sort': True}

这么看上去没有问题,确实声明为了对应的类型,但实际上并不能反映整个列表、元组的结构,比如我们只通过类型注解是不知道 names 里面的元素是什么类型的,只知道 names 是一个列表 list 类型,实际上里面都是字符串 str 类型。我们也不知道 version 这个元组的每一个元素是什么类型的,实际上是 int 类型。但这些信息我们都无从得知。因此说,仅仅凭借 list、tuple 这样的声明是非常“弱”的,我们需要一种更强的类型声明。 这时候我们就需要借助于 typing 模块了,它提供了非常“强“的类型支持,比如 List[str]Tuple[int, int, int] 则可以表示由 str 类型的元素组成的列表和由 int 类型的元素组成的长度为 3 的元组。所以上文的声明写法可以改写成下面的样子:

1
2
3
4
5
from typing import List, Tuple, Dict

names: List[str] = ['Germey', 'Guido']
version: Tuple[int, int, int] = (3, 7, 4)
operations: Dict[str, bool] = {'show': False, 'sort': True}

这样一来,变量的类型便可以非常直观地体现出来了。 目前 typing 模块也已经被加入到 Python 标准库中,不需要安装第三方模块,我们就可以直接使用了。

typing

下面我们再来详细看下 typing 模块的具体用法,这里主要会介绍一些常用的注解类型,如 List、Tuple、Dict、Sequence 等等,了解了每个类型的具体使用方法,我们可以得心应手的对任何变量进行声明了。 在引入的时候就直接通过 typing 模块引入就好了,例如:

1
from typing import List, Tuple

List

List、列表,是 list 的泛型,基本等同于 list,其后紧跟一个方括号,里面代表了构成这个列表的元素类型,如由数字构成的列表可以声明为:

1
var: List[int or float] = [2, 3.5]

另外还可以嵌套声明都是可以的:

1
var: List[List[int]] = [[1, 2], [2, 3]]

Tuple、NamedTuple

Tuple、元组,是 tuple 的泛型,其后紧跟一个方括号,方括号中按照顺序声明了构成本元组的元素类型,如 Tuple[X, Y] 代表了构成元组的第一个元素是 X 类型,第二个元素是 Y 类型。 比如想声明一个元组,分别代表姓名、年龄、身高,三个数据类型分别为 str、int、float,那么可以这么声明:

1
person: Tuple[str, int, float] = ('Mike', 22, 1.75)

同样地也可以使用类型嵌套。 NamedTuple,是 collections.namedtuple 的泛型,实际上就和 namedtuple 用法完全一致,但个人其实并不推荐使用 NamedTuple,推荐使用 attrs 这个库来声明一些具有表征意义的类。

Dict、Mapping、MutableMapping

Dict、字典,是 dict 的泛型;Mapping,映射,是 collections.abc.Mapping 的泛型。根据官方文档,Dict 推荐用于注解返回类型,Mapping 推荐用于注解参数。它们的使用方法都是一样的,其后跟一个中括号,中括号内分别声明键名、键值的类型,如:

1
2
def size(rect: Mapping[str, int]) -> Dict[str, int]:
return {'width': rect['width'] + 100, 'height': rect['width'] + 100}

这里将 Dict 用作了返回值类型注解,将 Mapping 用作了参数类型注解。 MutableMapping 则是 Mapping 对象的子类,在很多库中也经常用 MutableMapping 来代替 Mapping。

Set、AbstractSet

Set、集合,是 set 的泛型;AbstractSet、是 collections.abc.Set 的泛型。根据官方文档,Set 推荐用于注解返回类型,AbstractSet 用于注解参数。它们的使用方法都是一样的,其后跟一个中括号,里面声明集合中元素的类型,如:

1
2
def describe(s: AbstractSet[int]) -> Set[int]:
return set(s)

这里将 Set 用作了返回值类型注解,将 AbstractSet 用作了参数类型注解。

Sequence

Sequence,是 collections.abc.Sequence 的泛型,在某些情况下,我们可能并不需要严格区分一个变量或参数到底是列表 list 类型还是元组 tuple 类型,我们可以使用一个更为泛化的类型,叫做 Sequence,其用法类似于 List,如:

1
2
def square(elements: Sequence[float]) -> List[float]:
return [x ** 2 for x in elements]

NoReturn

NoReturn,当一个方法没有返回结果时,为了注解它的返回类型,我们可以将其注解为 NoReturn,例如:

1
2
def hello() -> NoReturn:
print('hello')

Any

Any,是一种特殊的类型,它可以代表所有类型,静态类型检查器的所有类型都与 Any 类型兼容,所有的无参数类型注解和返回类型注解的都会默认使用 Any 类型,也就是说,下面两个方法的声明是完全等价的:

1
2
3
4
5
def add(a):
return a + 1

def add(a: Any) -> Any:
return a + 1

原理类似于 object,所有的类型都是 object 的子类。但如果我们将参数声明为 object 类型,静态参数类型检查便会抛出错误,而 Any 则不会,具体可以参考官方文档的说明:https://docs.python.org/zh-cn/3/library/typing.html?highlight=typing#the-any-type

TypeVar

TypeVar,我们可以借助它来自定义兼容特定类型的变量,比如有的变量声明为 int、float、None 都是符合要求的,实际就是代表任意的数字或者空内容都可以,其他的类型则不可以,比如列表 list、字典 dict 等等,像这样的情况,我们可以使用 TypeVar 来表示。 例如一个人的身高,便可以使用 int 或 float 或 None 来表示,但不能用 dict 来表示,所以可以这么声明:

1
2
3
4
height = 1.75
Height = TypeVar('Height', int, float, None)
def get_height() -> Height:
return height

这里我们使用 TypeVar 声明了一个 Height 类型,然后将其用于注解方法的返回结果。

NewType

NewType,我们可以借助于它来声明一些具有特殊含义的类型,例如像 Tuple 的例子一样,我们需要将它表示为 Person,即一个人的含义,但但从表面上声明为 Tuple 并不直观,所以我们可以使用 NewType 为其声明一个类型,如:

1
2
Person = NewType('Person', Tuple[str, int, float])
person = Person(('Mike', 22, 1.75))

这里实际上 person 就是一个 tuple 类型,我们可以对其像 tuple 一样正常操作。

Callable

Callable,可调用类型,它通常用来注解一个方法,比如我们刚才声明了一个 add 方法,它就是一个 Callable 类型:

1
print(Callable, type(add), isinstance(add, Callable))

运行结果:

1
typing.Callable <class 'function'> True

在这里虽然二者 add 利用 type 方法得到的结果是 function,但实际上利用 isinstance 方法判断确实是 True。 Callable 在声明的时候需要使用 Callable[[Arg1Type, Arg2Type, ...], ReturnType] 这样的类型注解,将参数类型和返回值类型都要注解出来,例如:

1
2
3
4
5
def date(year: int, month: int, day: int) -> str:
return f'{year}-{month}-{day}'

def get_date_fn() -> Callable[[int, int, int], str]:
return date

这里首先声明了一个方法 date,接收三个 int 参数,返回一个 str 结果,get_date_fn 方法返回了这个方法本身,它的返回值类型就可以标记为 Callable,中括号内分别标记了返回的方法的参数类型和返回值类型。

Union

Union,联合类型,Union[X, Y] 代表要么是 X 类型,要么是 Y 类型。 联合类型的联合类型等价于展平后的类型:

1
Union[Union[int, str], float] == Union[int, str, float]

仅有一个参数的联合类型会坍缩成参数自身,比如:

1
Union[int] == int

多余的参数会被跳过,比如:

1
Union[int, str, int] == Union[int, str]

在比较联合类型的时候,参数顺序会被忽略,比如:

1
Union[int, str] == Union[str, int]

这个在一些方法参数声明的时候比较有用,比如一个方法,要么传一个字符串表示的方法名,要么直接把方法传过来:

1
2
3
4
5
6
def process(fn: Union[str, Callable]):
if isinstance(fn, str):
# str2fn and process
pass
elif isinstance(fn, Callable):
fn()

这样的声明在一些类库方法定义的时候十分常见。

Optional

Optional,意思是说这个参数可以为空或已经声明的类型,即 Optional[X] 等价于 Union[X, None]。 但值得注意的是,这个并不等价于可选参数,当它作为参数类型注解的时候,不代表这个参数可以不传递了,而是说这个参数可以传为 None。 如当一个方法执行结果,如果执行完毕就不返回错误信息, 如果发生问题就返回错误信息,则可以这么声明:

1
2
def judge(result: bool) -> Optional[str]:
if result: return 'Error Occurred'

Generator

如果想代表一个生成器类型,可以使用 Generator,它的声明比较特殊,其后的中括号紧跟着三个参数,分别代表 YieldType、SendType、ReturnType,如:

1
2
3
4
5
def echo_round() -> Generator[int, float, str]:
sent = yield 0
while sent >= 0:
sent = yield round(sent)
return 'Done'

在这里 yield 关键字后面紧跟的变量的类型就是 YieldType,yield 返回的结果的类型就是 SendType,最后生成器 return 的内容就是 ReturnType。 当然很多情况下,生成器往往只需要 yield 内容就够了,我们是不需要 SendType 和 ReturnType 的,可以将其设置为空,如:

1
2
3
4
def infinite_stream(start: int) -> Generator[int, None, None]:
while True:
yield start
start += 1

案例实战

接下来让我们看一个实际的项目,看看经常用到的类型一般是怎么使用的。 这里我们看的库是 requests-html,是由 Kenneth Reitz 所开发的,其 GitHub 地址为:https://github.com/psf/requests-html,下面我们主要看看它的源代码中一些类型是如何声明的。 这个库的源代码其实就一个文件,那就是 https://github.com/psf/requests-html/blob/master/requests_html.py,我们看一下它里面的一些 typing 的定义和方法定义。 首先 Typing 的定义部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from typing import Set, Union, List, MutableMapping, Optional

_Find = Union[List['Element'], 'Element']
_XPath = Union[List[str], List['Element'], str, 'Element']
_Result = Union[List['Result'], 'Result']
_HTML = Union[str, bytes]
_BaseHTML = str
_UserAgent = str
_DefaultEncoding = str
_URL = str
_RawHTML = bytes
_Encoding = str
_LXML = HtmlElement
_Text = str
_Search = Result
_Containing = Union[str, List[str]]
_Links = Set[str]
_Attrs = MutableMapping
_Next = Union['HTML', List[str]]
_NextSymbol = List[str]

这里可以看到主要用到的类型有 Set、Union、List、MutableMapping、Optional,这些在上文都已经做了解释,另外这里使用了多次 Union 来声明了一些新的类型,如 _Find 则要么是是 Element 对象的列表,要么是单个 Element 对象,_Result 则要么是 Result 对象的列表,要么是单个 Result 对象。另外 _Attrs 其实就是字典类型,这里用 MutableMapping 来表示了,没有用 Dict,也没有用 Mapping。 接下来再看一个 Element 类的声明:

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
class Element(BaseParser):
"""An element of HTML.
:param element: The element from which to base the parsing upon.
:param url: The URL from which the HTML originated, used for ``absolute_links``.
:param default_encoding: Which encoding to default to.
"""

__slots__ = [
'element', 'url', 'skip_anchors', 'default_encoding', '_encoding',
'_html', '_lxml', '_pq', '_attrs', 'session'
]

def __init__(self, *, element, url: _URL, default_encoding: _DefaultEncoding = None) -> None:
super(Element, self).__init__(element=element, url=url, default_encoding=default_encoding)
self.element = element
self.tag = element.tag
self.lineno = element.sourceline
self._attrs = None

def __repr__(self) -> str:
attrs = ['{}={}'.format(attr, repr(self.attrs[attr])) for attr in self.attrs]
return "<Element {} {}>".format(repr(self.element.tag), ' '.join(attrs))

@property
def attrs(self) -> _Attrs:
"""Returns a dictionary of the attributes of the :class:`Element <Element>`
(`learn more <https://www.w3schools.com/tags/ref_attributes.asp>`_).
"""
if self._attrs is None:
self._attrs = {k: v for k, v in self.element.items()}

# Split class and rel up, as there are ussually many of them:
for attr in ['class', 'rel']:
if attr in self._attrs:
self._attrs[attr] = tuple(self._attrs[attr].split())

return self._attrs

这里 __init__ 方法接收非常多的参数,同时使用 _URL_DefaultEncoding 进行了参数类型注解,另外 attrs 方法使用了 _Attrs 进行了返回结果类型注解。 整体看下来,每个参数的类型、返回值都进行了清晰地注解,代码可读性大大提高。 以上便是类型注解和 typing 模块的详细介绍。