0%

Python

内容选自即将出版的《Python3 反爬虫原理与绕过实战》,本次公开书稿范围为第 6 章——文本混淆反爬虫。本篇为第 6 章中的第 2 小节,第 3、4 小节已发,直达链接:

其余小节将逐步放送

CSS 偏移反爬虫

CSS 偏移反爬虫指的是利用 CSS 样式将乱序的文字排版为人类正常阅读顺序的行为。这个概念不是很好理解,我们可以通过对比两段文字来加深对这个概念的理解。

  • HTML 文本中的文字:我的学号是 1308205,我在北京大学读书。
  • 浏览器显示的文字:我的学号是 1380205,我在北京大学读书。

爬虫提取到的学号是 1308205,但用户在浏览器中看到的却是 1380205。如果不细心观察,爬虫工程师很容易被爬取结果糊弄。这种混淆方法和图片伪装一样,是不会影响用户阅读的。让人好奇的是,浏览器如何将 HTML 文本中的数字按照开发者的意愿排序或放置呢?这种放置规则是如何运作的呢?我们可以通过一个具体的例子来了解 CSS 偏移反爬虫的应用和绕过方法。

6.2.1 CSS 偏移反爬虫绕过实战

示例 5:CSS 偏移反爬虫示例。 网址:http://www.porters.vip/confusion/flight.html。 任务:爬取航班查询和机票销售网站页面中的航站名称、所属航空公司和票价,页面内容如图 6-4 所示。 图 6-4 示例 5 页面 在编写 Python 代码之前,我们需要确定目标数据的元素定位。航空公司名称元素定位如图 6-5 所示。 图 6-5 航空公司名称元素定位结果 航空公司名称包裹在没有属性的 span 标签中,但该 span 标签包裹在 class 属性为 air g-tips 的 div 标签中。接下来我们看一下航站名称的元素定位,定位结果如图 6-6 所示。 图 6-6 航站名称元素定位结果 航站名称包裹在没有属性的 h2 标签中,h2 标签包裹在 class 为 sep-lf 的 div 标签中。 我们再看一下票价的元素定位,定位结果如图 6-7 所示。 图 6-7 票价的元素定位结果 页面中显示的票价为 467,但是在网页中却有两组不同的数字,其中一组是[7, 7, 7],而另一组是 [6, 4],这看起来就有点奇怪了。 难道是网页显示有问题? 按照正常排序来说,这架航班的票价应该是 77 764 才对。我们可以查看第二架航班信息的价格,思考是网页显示问题还是做了什么反爬虫措施。第二架航班的票价元素定位结果如图 6-8 所示。 图 6-8 第二架航班的票价元素定位结果 结果与第一架航班的票价显示有同样的问题:网页显示内容和 HTML 代码中的内容不一致。我们分析一下 HTML 代码,看一看是否能找到什么线索。第一架航班票价的 HTML 代码为:

1
2
3
4
5
6
7
8
9
10
11
<span class="prc_wp" style="width:48px"> 
<em class="rel">
<b style="width:48px;left:-48px">
<i style="width: 16px;">7</i>
<i style="width: 16px;">7</i>
<i style="width: 16px;">7</i>
</b>
<b style="width: 16px;left:-32px">6</b>
<b style="width: 16px;left:-48px">4</b>
</em>
</span>

代码中有 3 对 b 标签,第 1 对 b 标签中包含 3 对 i 标签,i 标签中的数字都是 7,也就是说第 1 对 b 标签的显示结果应该是 777。而第 2 对 b 标签中的数字是 6,第 3 对 b 标签中的数字是 4。 这些数字与页面所显示票价 467 的关系是什么呢? 这一步找到的标签和数字有可能是数据源,但是数字的组合有很多种可能,如图 6-9 所示。 图 6-9 数字组合推测 5 个数字的组合结果太多了,我们必须找出其中的规律,这样就能知道网页为什么显示 467 而不是 764 或者 776 。在仔细查看过后,发现每个带有数字的标签都设定了样式。第 1 对 b 标签的样式为:

1
width:48px;left:-48px

第 2 对 b 标签的样式为:

1
width: 16px;left:-32px

第 3 对 b 标签的样式为:

1
width: 16px;left:-48px

i 标签对的样式是相同的,都是:

1
width: 16px;

另外,还注意到最外层的 span 标签对的样式为:

1
width:48px

如果按照 CSS 样式这条线索来分析的话,第 1 对 b 标签中的 3 对 i 标签刚好占满 span 标签对的位置,其位置如图 6-10 所示。 图 6-10 span 标签对和 i 标签对位置图 此时网页中显示的价格应该是 777,但是由于第 2 和第 3 对 b 标签中有值,所以我们还需要计算它们的位置。此时标签位置的变化如图 6-11 所示。 图 6-11 标签位置变化 右侧是标签位置变化后的结果,由于第 2 对 b 标签的位置样式是 left:-32px,所以第 2 对 b 标签中的值 6 就会覆盖原来第 1 对 b 标签中的中的第 2 个数字 7,此时页面应该显示的数字是 767。 按此规律推算,第 3 对 b 标签的位置样式是 left:-48px,这个标签的值会覆盖第 1 对 b 标签中的第 1 个数字 7,覆盖结果如图 6-12 所示,最后显示的票价是 467。 图 6-12 覆盖结果 根据结果来看这种算法是合理的,不过我们还需要对其进行验证,现在将第二架航班的 HTML 值 和 CSS 样式按照这个规律进行推算。最后推算得到的结果与页面显示结果相同,说明这个位置偏移的计算方法是正确的,这样我们就可以编写 Python 代码获取网页中的票价信息了。因为 b 标签包裹在 class 属性为 rel 的 em 标签下,所以我们要定位所有的 em 标签。对应的 Python 代码如下:

1
2
3
4
5
6
7
import requests 
import re
from parsel import Selector
url = 'http://www.porters.vip/confusion/flight.html'
resp = requests.get(url)
sel = Selector(resp.text)
em = sel.css('em.rel').extract()

接着定位所有的 b 标签。由于 b 标签中还有 i 标签,而且 i 标签的值是基准数据,所以可以直接提取。对应的 Python 代码如下:

1
2
3
4
5
6
7
for element in em: 
element = Selector(element)
# 定位所有的<b>标签
element_b = element.css('b').extract()
b1 = Selector(element_b.pop(0))
# 获取第 1 对<b>标签中的值(列表)
base_price = b1.css('i::text').extract()

接下来要提取其他 b 标签的偏移量和数字。对应的 Python 代码如下:

1
2
3
4
5
6
7
8
9
10
11
alternate_price = [] 
for eb in element_b:
eb = Selector(eb)
# 提取<b>标签的 style 属性值
style = eb.css('b::attr("style")').get()
# 获得具体的位置
position = ''.join(re.findall('left:(.*)px', style))
# 获得该标签下的数字
value = eb.css('b::text').get()
# 将<b>标签的位置信息和数字以字典的格式添加到替补票价列表中
alternate_price.append({'position': position, 'value': value})

然后根据偏移量决定基准数据列表的覆盖元素,实际上是完成图 6-11 中的操作。

1
2
3
4
5
6
7
8
9
10
for al in alternate_price: 
position = int(al.get('position'))
value = al.get('value')
# 判断位置的数值是否正整数
plus = True if position >= 0 else False
# 计算下标,以 16px 为基准
index = int(position / 16)
# 替换第一对<b>标签值列表中的元素,也就是完成值覆盖操作
base_price[index] = value
print(base_price)

最后将数据列表打印出来,得到的输出结果为:

1
2
['4', '6', '7'] 
['8', '7', '0', '5']

令人感到奇怪的是,输出结果中第一组票价数字与页面中显示的相同,但第二组却不同。这是因为第二架航班的票价基准数据有 4 个值。航班票价对应的 HTML 代码如下:

1
2
3
4
5
6
7
8
9
10
11
<em class="rel"> 
<b style="width:64px;left:-64px">
<i style="width: 16px;">8</i>
<i style="width: 16px;">3</i>
<i style="width: 16px;">9</i>
<i style="width: 16px;">5</i>
</b>
<b style="width: 16px;left:-32px">0</b>
<b style="width: 16px;left:-48px">7</b>
<b style="width: 16px;left:-16px">5</b>
</em>

覆盖操作是根据由偏移量计算得出的下标进行的,实际上就是列表元素的替换。当基准数据列表的元素数量超过包裹着 i 标签的 b 标签宽度时,我们就对列表进行切片,否则按照原来的替换规则进行。因此,需要对代码做一些调整。调整内容如下:

1
2
3
4
5
6
7
8
9
# 减号代表删除此行代码,加号代表新增代码
+ import re
- base_price = b1.css('i::text').extract()
+ b1_style = b1.css('b::attr("style")').get()
# 获得具体的位置
+ b1_width = ''.join(re.findall('width:(.*)px;', b1_style))
+ number = int(int(b1_width) / 16)
# 获取第 1 对 <b> 标签中的值(列表)
+ base_price = b1.css('i::text').extract()[:number]

如果列表中元素的数量超过标签宽度,那么后面的元素是不会显示的。比如 width:32px,每个标签占位宽度 16 px,那么即使 b 标签下有 5 个 i 标签(base_price=[1, 2 ,3 ,4 , 5]),在页面中也仅显示前面的两个数字。代码调整完毕后,再次运行代码。运行结果为:

1
2
['4', '6', '7'] 
['8', '7', '0', '5']

第二架航班的票价结果仍然跟页面显示的内容不同,但根据 CSS 宽度规则,我们之前分析的逻辑是正确的。为什么结果还是跟页面显示的不一样呢? 实际上并不是我们的逻辑和代码有错,而是页面显示错误。要注意的是,页面数据显示错误是常发生的事,我们只需要按照正确的逻辑编写代码即可。

6.2.2 去哪儿网反爬虫案例

去哪儿网是中国领先的在线旅游平台,覆盖全球 68 万余条航线,并与国内的旅游景点和航空公司进行了深度的合作。去哪儿网也有用到类似的反爬虫手段,我们一起来了解一下。 打开浏览器并访问 https://dwz.cn/d05zNKyq,页面内容如图 6-13 所示。 图 6-13 去哪儿网航班信息 航班票价对应的 HTML 代码如图 6-14 所示。 图 6-14 去哪儿网航班票价 HTML 代码 去哪儿网航班票价所对应的 HTML 代码结构和 CSS 与我们在示例 5 中见到的类似。我们可以大胆猜测,去哪儿网航班票价的显示规律与示例 5 中所用的方法也是类似的,感兴趣的同学可以按照 6.2.1 节的思路进行票价推算。去哪儿网航班票价中第 1 对 b 标签下的 i 标签数量与 width 是相匹配的,并未出现显示错误的问题。

6.2.3 小结

CSS 样式可以改变页面显示,但这种“改变”仅存在于浏览器(能够解释 CSS 的渲染工具)中,即使爬虫工程师借助渲染工具,也无法获得“见到”的内容。

新书福利

真是翘首以盼!《Python3 反爬虫原理与绕过实战》一书终于要跟大家见面了!为了感谢大家对韦世东和本书的期待与支持,在新书发布时会举办多场送书活动和限时折扣活动。 想要与作者韦世东交流或者参加新书发布活动的朋友可以扫描二维码进群与我互动哦!

转载说明

本篇内容摘自出版图书《Python3 反爬虫原理与绕过实战》,欢迎各位好友与同行转载! 记得带上相关的版权信息哦😊。

Python

内容选自即将出版的《Python3 反爬虫原理与绕过实战》,本次公开书稿范围为第 6 章——文本混淆反爬虫。本篇为第 6 章中的第 3 小节,第 4 小节字体反爬虫已发布,其余小节将逐步放送

新书福利

真是翘首以盼!《Python3 反爬虫原理与绕过实战》一书终于要跟大家见面了!为了感谢大家对韦世东和本书的期待与支持,在新书发布时会举办多场送书活动和限时折扣活动。

想要与作者韦世东交流或者参加新书发布活动的朋友可以扫描二维码进群与我互动哦!

SVG 映射反爬虫

SVG 是用于描述二维矢量图形的一种图形格式。它基于 XML 描述图形,对图形进行放大或缩小操作都不会影响图形质量。矢量图形的这个特点使得它被广泛应用在 Web 网站中。 接下来我们要了解的反爬虫手段正是利用 SVG 实现的,这种反爬虫手段用矢量图形代替具体的文字,不会影响用户正常阅读,但爬虫程序却无法像读取文字那样获得 SVG 图形中的内容。由于 SVG 中的图形代表的也是一个个文字,所以在使用时必须在后端或前端将真实的文字与对应的 SVG 图形进行映射和替换,这种反爬虫手段被称为 SVG 映射反爬虫。

6.3.1 SVG 映射反爬虫绕过实战

示例 6:SVG 映射反爬虫示例。 网址:http://www.porters.vip/confusion/food.html。 任务:爬取美食商家评价网站页面中的商家联系电话、店铺地址和评分数据,页面内容如图 6-15 所示。 图 6-15 示例 6 页面 在编写 Python 代码之前,我们需要确定目标数据的元素定位。在定位过程中,发现一个与以往不同的现象:有些数字在 HTML 代码中并不存在。例如口味的评分数据,其元素定位如图 6-16 所示。 图 6-16 评分数据中口味分数元素定位 根据页面显示内容,HTML 代码中应该是 8.7 才对,但实际上我们看到的却是:

1
<span class="item">口味:<d class="vhkjj4"></d>.7</span>

HTML 代码中有数字 7 和小数点,但没有 8 这个数字,似乎数字 8 的位置被 d 标签占据。而商家电话号码处的显示就更奇怪了,一个数字都没有。商家电话对应的 HTML 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
<div class="col more"> 
电话:
<d class="vhkbvu"></d>
<d class="vhk08k"></d>
<d class="vhk08k"></d>
<d class="">-</d>
<d class="vhk84t"></d>
<d class="vhk6zl"></d>
<d class="vhkqsc"></d>
<d class="vhkqsc"></d>
<d class="vhk6zl"></d>
</div>

包含很多的 d 标签,难道它使用 d 标签进行占位,然后用元素进行覆盖吗?我们可以将 d 标签的数量和数字的数量进行对比,发现它们的数量是相同的,也就是说一对 d 标签代表一个数字。 每一对 d 标签都有 class 属性,有些 class 属性值是相同的,有些则不同。我们再将 class 属性值与数字进行对比,看一看能否找到规律,如图 6-17 所示。 图 6-17 class 属性值和数字的对比 从图 6-17 中可以看出,class 属性值和数字是一一对应的,如属性值 vhk08k 与数字 0 对应。根据这个线索,我们可以猜测每个数字都与一个属性值对应,对应关系如图 6-18 所示。 图 6-18 数字与属性值对应关系 浏览器在渲染页面的时候就会按照这个对应关系进行映射,所以页面中显示的是数字,而我们在 HTML 代码中看到的则是这些 class 属性值。浏览器在渲染时将 HTML 中的 d 标签与数字按照此关系进行映射,并将映射结果呈现在页面中。映射逻辑如图 6-19 所示。 图 6-19 映射逻辑 我们的爬虫代码可以按照同样的逻辑实现映射功能,在解析 HTML 代码时将 d 标签的 class 属性值取出来,然后进行映射即可得到页面中显示的数字。如何在爬虫代码中实现映射关系呢?实际上网页中使用的是“属性名数字”这种结构,Python 中内置的字典正好可以满足我们的需求。我们可以用 Python 代码测试一下,代码如下:

1
2
3
4
5
6
7
8
9
# 定义映射关系
mappings = {'vhk08k': 0, 'vhk6zl': 1, 'vhk9or': 2,
'vhkfln': 3, 'vhkbvu': 4, 'vhk84t': 5,
'vhkvxd': 6, 'vhkqsc': 7, 'vhkjj4': 8,
'vhk0f1': 9}
# HTML 中得到的属性值
html_d_class = 'vhkvxd'
# 将映射后的结果打印输出
print(mappings.get(html_d_class))

这段代码的逻辑是:首先定义属性值与数字的映射关系,然后假设一个 HTML 中 d 标签的属性值,接着将这个属性值的映射结果打印出来。代码运行后得到的结果为:

1
6

运行结果说明映射这种方法是可行的。接着我们试一试将商家的联系电话映射出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 定义映射关系
mappings = {'vhk08k': 0, 'vhk6zl': 1, 'vhk9or': 2,
'vhkfln': 3, 'vhkbvu': 4, 'vhk84t': 5,
'vhkvxd': 6, 'vhkqsc': 7, 'vhkjj4': 8,
'vhk0f1': 9}
# 商家联系电话 class 属性
html_d_class = ['vhkbvu', 'vhk08k', 'vhk08k',
'', 'vhk84t', 'vhk6zl',
'vhkqsc', 'vhkqsc', 'vhk6zl']

phone = [mappings.get(i) for i in html_d_class]
# 将映射后的结果打印输出
print(phone)

运行结果为:

1
[4, 0, 0, None, 5, 1, 7, 7, 1]

我们使用映射的方法得到了商家联系电话,说明 SVG 映射反爬虫已经被我们绕过了。

6.3.2 大众点评反爬虫案例

这种映射手段不仅仅出现在本书的示例中,在大型网站中也有应用。大众点评是中国领先的本地生活信息及交易平台,也是全球最早建立的独立第三方消费点评网站。大众点评不仅为用户提供商户信息、消费点评及消费优惠等信息服务,同时提供团购、餐厅预订、外卖和电子会员卡等 O2O(Online To Offline)交易服务。大众点评网站也使用了映射型反爬虫手段,打开浏览器并访问 https://www.dianping.com/shop/14741057,页面如图 6-20 所示。 图 6-20 大众点评商家信息页 大众点评的商家信息页主要用于展示消费者对商家的各项评分、商家电话、店铺地址和推荐菜品等。我们可以看一看商家电话或评分的 HTML 代码,如图 6-21 所示。 图 6-21 商家电话 HTML 代码 大众点评中的商家号码并不是全部使用 d 标签代替,其中有部分使用了数字。但是仔细观察一下就可以发现商家号码的数量等于 d 标签数量加上数字的数量,说明 d 标签的 class 属性值与数字也有可能是一一对应的映射关系。感兴趣的同学可以使用示例 6 中的方法,尝试映射大众点评案例中的数字。 如果这种手段的绕过方法这么简单的话,那么它早就被淘汰了,为什么连大众点评这样的大型网站都会使用呢?我们继续往下看,大众点评的商家营业时间部分的 HTML 代码如图 6-22 所示。 图 6-22 大众点评商家营业时间 除了刚才的数字映射之外,大众点评还对中文进行了映射。此时如果按照示例 6 中人为地将 class 值和对应的文字进行映射的话,就非常麻烦了。试想一下,如果网页中所有的文字都使用这种映射反爬虫的手段,那么爬虫工程师要如何应对呢?对所有用到的文字进行映射吗? 这不可能做到,其中要完成映射的包括 10 个数字、26 个英文字母和几千个常用汉字。而且目标网站一旦更改文字的对应关系,那么爬虫工程师就需要重新映射所有文字。面对这样的问题,我们必须找到文字映射规律,并且能够使用 Python 语言实现映射算法。如此一来,无论目标网站文字映射的对应关系如何变化,我们都能够使用这套映射算法得到正确的结果。 这种映射关系在网页中是如何实现的呢?是使用 JavaScript 在页面中定义数组吗?还是异步请求API 拿到 JSON 数据?这都有可能,接下来我们就去寻找答案。

6.3.3 SVG 反爬虫原理

映射关系不可能凭空出现,一定使用了某种技术特性。HTML 中与标签 class 属性相关的只有 JavaScript 和 CSS。根据这个线索,我们需要继续对示例 6 进行分析。案例中商家电话的 HTML 代码为:

1
2
3
4
5
6
7
8
9
10
11
<div class="col more">电话:
<d class="vhkbvu"></d>
<d class="vhk08k"></d>
<d class="vhk08k"></d>
<d class="">-</d>
<d class="vhk84t"></d>
<d class="vhk6zl"></d>
<d class="vhkqsc"></d>
<d class="vhkqsc"></d>
<d class="vhk6zl"></d>
</div>

我们可以随意选择一对 d 标签,然后观察它对应的 CSS 样式有没有可以深入分析的线索,如果没有线索再看 JavaScript。 d 标签的 CSS 样式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
d[class^="vhk"] { 
width: 14px;
height: 30px;
margin-top: -9px;
background-image: url(../font/food.svg);
background-repeat: no-repeat;
display: inline-block;
vertical-align: middle;
margin-left: -6px;
}
.vhkqsc {
background: -288.0px -141.0px;
}

d 标签样式看上去没有什么特别之处,只是设置了 background 属性的坐标值。但是上方 d 标签的公共样式中设置了背景图片,我们可以复制背景图片的地址,在浏览器的新标签页中打开,d 标签背景图如图 6-23 所示。 图 6-23 标签背景图 d 标签的背景图中全部都是数字,这些无序的数字共有 4 行。但这好像不是一张大图片,我们查看该图片页面的源代码,内容如图 6-24 所示。 图 6-24 图片页面源代码 源代码中前两行表明这是一个 SVG 文件,该文件中使用 text 标签定义文本, style 标签用于设置文本样式, text 标签定义的文本正是图片页面显示的数字。难道这些无序的数字就是我们在页面中看到的电话号码和评分数字? 除了 class 属性值为 vhkbvu 的 d 标签,其他标签也使用了这个的 CSS 样式,但每对 d 标签的坐标定位都不同。它们的坐标定位如下:

1
2
3
4
5
6
7
8
9
.vhkbvu { 
background: -386px -97px;
}
.vhk08k {
background: -274px -141px;
}
.vhk84t {
background: -176px -141px;
}

坐标是定位数字的关键,要想知道坐标的计算方法,必须了解一些关于 SVG 的知识。 在本节开始的时候,我们简单地了解了 SVG 的概念,知道 SVG 是基于 XML 的。实际上它是用文本格式的描述性语言来描述图像内容的,因此 SVG 是一种与图像分辨率无关的矢量图形格式。打开文本编辑器,并在新建的文件中写入以下内容:

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8" standalone="no"?> 
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/
DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/
1999/xlink" width="250px" height="250.0px">
<text x='10' y='30'>hello,world</text>
</svg>

将该文件保存为 test.svg,然后使用浏览器打开 test.svg 文件,显示内容如图 6-25 所示。 图 6-25 test.svg 显示内容 代码前 3 行声明文件类型,第 4 行~第 5 行定义了 SVG 内容块和画布宽高,第 6 行使用 text 标签定义了一段文本并指定了文本的坐标。这段文本就是我们在浏览器中看到的内容,而代码中的 x 坐标和 y 坐标则用于确定该文本在画布中的位置,坐标规则如下。

  • 以页面的左上角为零坐标点,即坐标值为 (0, 0)。
  • 坐标以像素为单位。
  • x 轴的正方向为从左到右,y 轴的正方向是从上到下。
  • n 个字符可以有 n 个位置参数。

如果字符数量大于位置参数数量,那么没有位置参数的字符将以最后一个位置参数为零坐标点,并按原文顺序排列。 看上去并不是很好理解,我们可以通过修改代码来理解坐标轴的定义。首先是 x 轴, text 标签中的 x 代表列表字符在页面中的 x 轴位置,test.svg 中的 x 值为 10,现在我们将其设为 0 ,保存后刷新网页,页面内容如图 6-26 所示。 图 6-26 x 为 0 时的 test.svg 显示内容 x 的值为 0 时,文本紧贴浏览器左侧。而 x 的值为 10 时,文本距离浏览器左侧有一定的距离,这说明 x 的值能够决定文字所在的位置。现在我们将代码中 x 对应的值改为“10 50 30 40 20 60”(注意这里特意将第 2个数字 20与第 5个数字互换了位置),这样做是为了设定前 6个字符的坐标位置。 此时,第 1 个字符的位置参数为 10,第 2 个字符的位置参数为 50,第 3 个字符的位置参数为 30,以此类推,页面中正常显示的文字顺序应该是:

1
holle,world

但是由于我们调换了第 2 个字符和第 5 个字符的位置参数,即字母 e 和字母 o 的位置互换,如图 6-27 所示。 图 6-27 设定多个 x 值的 svg 图 6-27 中文字顺序与我们猜测的顺序是一样的,这说明 SVG 中每个字符都可以有自己的 x 轴坐标值。yx 同理,每个字符都可以有自己的 y 轴坐标值。虽然我们只设定了 6 个位置参数, svg 中的字符却有 11 个,但没有设定位置参数的字符依然能够按照原文顺序排序。在了解 SVG 基本知识之后,我们回头看一下案例中所使用的 SVG 文件中坐标参数的设定,图 6-23 中的字符与图 6-24 图片页源代码中的字符一一对应,且每个字符都设定了 x 轴的位置参数,而 y 轴则只有 1 个值。 在了解位置参数之后,我们还需要弄清楚字符定位的问题。浏览器根据 CSS 样式中设定的坐标和元素宽高来确定 SVG 中对应数字。x 轴的正方向为从左到右,y 轴的正方向是从上到下,如图 6-28 所示。 图 6-28 SVG x 轴和 y 轴与位置参数的关系 而 CSS 样式中的 x 轴与 y 轴是相反的,也就是说 CSS 样式中 x 轴是负数向右的,y 轴是负数向下的,如图 6-29 所示。 图 6-29 CSS x 轴和 y 轴与位置参数的关系 所以当我们需要在 CSS 中定位 SVG 中的字符位置时,需要用负数表示。我们可以通过一个例子来理解它们的关系,现在需要在 CSS 中定位图 6-30 中第 1 行的第 1 个字符的中心点。 图 6-30 SVG 假设字符大小为 14 px,那么 SVG 的计算规则如下。

  • 字符在x轴中心点的计算规则为:字符大小除以2,再加字符的x轴起点位置参数,即14÷2+0 等于 7。
  • 字符在 y 轴中心点的计算规则为:y 轴高度减字符 y 轴起点减字符大小,其值除以 2 后加上字符 y 轴起点位置参数,最后再加上字符大小数值的一半,即(38−0−14)÷2+0+7 等于 19。

最后得到 SVG 的坐标为:

1
x='7' y='19'

CSS 样式的 x 轴和 y 轴与 SVG 是相反的,所以 CSS 样式中对该字符的定位为:

1
\-7px -19px

这样就能够定位到指定字符的中心点了。但是如果要在 HTML 页面中完整显示该字符,那么还需要为 HTML 中对应的标签设置宽高样式,如:

1
2
width: 14px; 
height: 30px;

在了解了 SVG 与 CSS 样式的关联关系后,我们就能够根据 CSS 样式映射出 SVG 中对应的字符。 在实际场景中,我们需要让程序能够自动处理 CSS 样式和 SVG 的映射关系,而不是人为地完成这些 工作。以示例 6 中的 SVG 和 CSS 样式为例,假如我们需要用 Python 代码实现自动映射功能,首先我 们就需要拿到这两个文件的 URL,如:

1
2
url_css = 'http://www.porters.vip/confusion/css/food.css' 
url_svg = 'http://www.porters.vip/confusion/font/food.svg'

还有需要映射的 HTML 标签的 class 属性值,如:

1
css_class_name = 'vhkbvu'

接下来使用 Requests 库向 URL 发出请求,拿到文本内容。对应代码如下:

1
2
3
import requests 
css_resp = requests.get(url_css).text
svg_resp = requests.get(url_svg).text

提取 CSS 样式文件中标签属性对应的坐标值,这里使用正则进行匹配即可。对应代码如下:

1
2
3
4
5
6
7
8
import re 
pile = '.%s{background:-(d+)px-(d+)px;}' % css_class_name
pattern = re.compile(pile)
css = css_resp.replace('n', '').replace(' ', '')
coord = pattern.findall(css)
if coord:
x, y = coord[0]
x, y = int(x), int(y)

此时得到的坐标值是正数,可以直接用于 SVG 字符定位。定位前我们要先拿到 SVG 中所有 text 标签的 Element 对象:

1
2
3
from parsel import Selector 
svg_data = Selector(svg_resp)
texts = svg_data.xpath('//text')

然后获取所有 text 标签中的 y 值,接着我们将上一步得到的 Element 对象进行循环取值即可:

1
axis_y = [i.attrib.get('y') for i in texts if y <= int(i.attrib.get('y'))][0]

得到 y 值后就可以开始字符定位了。要注意的是,SVG 中 text 标签的 y 值与 CSS 样式中得到的 y 值并不需要完全相等,因为样式可以随意调整,比如 CSS 样式中-90 和-92 对于 SVG 的定位来说并没有什么差别,所以我们只需要知道具体是哪一个 text 即可。 那么如何确定是哪一个 text呢? 我们可以用排除法来确定,假如当前 CSS 样式中的 y 值是-97,那么在 SVG 中 text 的 y 值就不可能小于 97,我们只需要取到比 97 大且最相近的 text 标签 y 值即可。比如当前 SVG 所有 text 标签的 y 值为:

1
[38, 83, 120, 164]

那么大于 97 且最相近的是 120。将这个逻辑转化为代码:

1
axis_y = [i.attrib.get('y') for i in texts if y <= int(i.attrib.get('y'))][0]

得到 y 值后就可以确定具体是哪个 text 标签了。对应代码如下:

1
svg_text = svg_data.xpath('//text[@y="%s"]/text()' % axis_y).extract_first()

接下来需要确认 SVG 中的文字大小,也就是需要找到 font-size 属性的值。对应代码如下:

1
font_size = re.search('font-size:(d+)px', svg_resp).group(1)

得到 font-size 的值后,我们就可以定位具体的字符了。x 轴有多少个字符呢?刚才我们拿到的 svg_text 就是指定的 text 标签中的字符:

1
'671260781104096663000892328440489239185923'

我们需要计算字符串长度吗?并不用,我们知道,每个字符大小为 14 px,只需要将 CSS 样式中的 x 值除以字符大小,得到的就是该字符在字符串中的位置。除法得到的结果有可能是整数也有可能是非整数,当结果是整数是说明定位完全准确,我们利用切片特性就可以拿到字符。如果结果是非整数,就说明定位不完全准确,由于字符不可能出现一半,所以我们利用地板除(编程语言中常见的向下取整除法,返回商的整数部分。)就可以拿到整数:

1
position = x // int(font_size) # 结果为 27

也就是说 CSS 样式 vhkbvu 映射的是 SVG 中第 4 行文本的第 27 个位置的值。映射结果如图 6-31 所示。 图 6-31 映射结果 然后再利用切片特性拿到字符。对应代码如下:

1
2
number = svg_text[position] 
print(number)

代码运行结果为 4。我们还可以尝试其他的 class 属性值,最后得到的结果与页面显示的字符都是相同的,说明这种映射算法是正确的。至此,我们已经完成了对映射型反爬虫的绕过。

6.3.4 小结

与 6.1 节和 6.2 节相同,本节示例所用的反爬虫手段,即使借助渲染工具也无法获得“见到”的内容。SVG 映射反爬虫利用了浏览器与编程语言在渲染方面的差异,以及 SVG 与 CSS 定位这样的前端知识。如果爬虫工程师不熟悉渲染原理和前端知识,那么这种反爬虫手段就会带来很大的困扰。

转载说明

本篇内容摘自出版图书《Python3 反爬虫原理与绕过实战》,欢迎各位好友与同行转载! 记得带上相关的版权信息哦😊。

Python

很久没有做爬虫破解类相关的分享了,之前交流群里有朋友提问谷歌系的reCAPTCHA V2 验证码怎么破,因为工作的原因我是很久之后才看到的,也不知道那位朋友后来成功了没有。所以今天就来跟大家分享一下 reCAPTCHA V2 的破解。 (小马补充:想加交流群的朋友,进入公众号下方,点击技术交流,有读者群和交流群,点击后都会弹出崔老师的二维码,扫微信二维码拉群~) 如果大家访问国外的一些网站的话,想必肯定见过这样的一个验证码,它上面写着「I’m not a robot」,意为「我不是机器人」,验证码长这个样子: 这时候,只要我们点击最前面的复选框,验证码算法会首先利用其「风险分析引擎」做一次安全检测,如果直接检验通过的话,我们会直接得到如下的结果: 如果算法检测到当前系统存在风险,比如可能是陌生的网络环境,可能是模拟程序,会需要做二次校验。它会进一步弹出类似如下的内容: 比如上面这张图,验证码页面会出现九张图片,同时最上方出现文字「树木」,我们需要点选下方九张图中出现「树木」的图片,点选完成之后,可能还会出现几张新的图片,我们需要再次完成点选,最后点击「验证」按钮即可完成验证。 或者我们可以点击下方的「耳机」图标,这时候会切换到听写模式,验证码会变成这样: 这时候我们如果能填写对验证码读的音频内容,同样可以通过验证。 这两种方式都可以通过验证,验证完成之后,我们才能完成表单的提交,比如完成登录、注册等操作。 这种验证码叫什么名字? 这个验证码就是 Google 的 reCAPTCHA V2 验证码,它就属于行为验证码的一种,这些行为包括点选复选框、选择对应图片、语音听写等内容,只有将这些行为校验通过,此验证码才能通过验证。相比于一般的图形验证码来说,此种验证码交互体验更好、安全性会更高、破解难度更大。 许多国外的网站都采用了此种验证码,由于某些原因,在国内其实无法直接使用,但只需要将验证码的域名更换为 recaptcha.net 同样是可以使用的,所以有时候我们在国内某些站点同样能看到它的身影。 其实上文所介绍的验证码仅仅是 reCAPTCHA 验证码的一种形式,是 V2 的显式版本,另外其 V2 版本还有隐式版本,隐式版本在校验的时候不会再显式地出现验证页面,它是通过 JavaScript 将验证码和提交按钮进行绑定,在提交表单的时候会自动完成校验。除了 V2 版本,Google 又推出了最新的 V3 版本,reCAPTCHA V3 验证码会为根据用户的行为来计算一个分数,这个分数代表了用户可能为机器人的概率,最后通过概率来判断校验是否可以通过。其安全性更高、体验更好。 具体的内容大家可以参考 reCAPTCHA 的官方介绍:https://developers.google.com/recaptcha。 那么在做爬虫的时候,如果我们遇到了这样的验证码?该怎么办呢?不要着急,这篇文章就来介绍一个解决方案。

机器学习 vs 识别服务

之前我在写上一篇如何识别滑动验证码问题的时候,当时朋友留言问我能不能做一个机器学习的,我回复了,我说当然没问题,你等着,我这周就做。 我那周从周一做到周五,我记得用的应该是 yolo,反复修改,小马还经常过来催崔稿,耗费良久,然后就在那周周五晚上的23:59分,我灵机一动,终于明白了。 去他的机器学习,有服务不好吗? reCAPTCHA 本身比极验还要复杂,国内网站我暂时没看到破解的,然后这次是用的俄罗斯的一个服务商 2Captcha 提供的 图像识别和一系列行为验证码的识别服务。 破解验证码背后有图像识别算法和大量人力的支撑,如果我们仅仅是简单的图形验证码,其可以通过一些图像识别算法将内容识别出来转化为文本内容。如果是较为复杂的图形验证码或者像 reCAPTCHA 类似的行为验证码,其背后会有人来对验证码进行模拟,然后返回其验证成功后的秘钥,我们利用其结果便可以完成一些验证码的绕过。 当然这种网站肯定是要收费的,按照 1000 次识别为单位,其花费的费用为 0.5 美刀到 2.99 美刀不等,比如非常简单的图形验证码可能就是 0.5 美刀,这种验证码对于其人力和算力资源消耗都是相对较小的,对于复杂的 reCAPTCHA 验证码,就要花 2.99 美刀了,因为识别这么一个验证码并不容易,其背后的人可能需要看好多图,点选好多次才能完成一次成功的验证。 目前我用的服务的收费标准是这样的: 具体的内容或者更新大家可以到其官方说明 https://2captcha.com/2captcha-api#rates 去查看。 后面我用他的服务来破解 reCAPTCHA,当然类比其他服务也是可以的,过程大概都是这样。

准备工作

要使用 2Captcha,第一步当然是注册一下它的账号了,注册完成之后我们可以进入到 2Captcha 的控制台,类似于这样子: 在这里我们可以看到账户余额、API KEY、FAQ 等配置。 这里最重要的就是 API KEY 了,它是我们用来使用 2Captcha 的凭证,我们将它复制下来,后面我们会在代码中使用它。 好,准备工作完成了,我们接下来进入正式内容。

2Captcha for reCAPTCHA V2

在上文我们已经介绍过 reCAPTCHA V2 的使用和交互流程了,下面我们来介绍下其识别和绕过的基本流程。 在这里我们就拿官方的 reCAPTCHA V2 的示例网站来做演示吧,其网址为:https://www.google.com/recaptcha/api2/demo,打开之后界面如下所示 在这里可以看到有一个表单,上面有一些输入框,下方是 reCAPTCHA V2 验证码。 要识别这个验证码,第一步便是找到这个验证码 sitekey,这个是验证码的唯一标识。 我们打开浏览器的开发者工具,查看其页面源码,首先找到 reCAPTCHA 的源代码,如下图所示: 可以看到 reCAPTCHA 是对应了一个 iframe,我们看到的 reCAPTCHA 内容都是在 iframe 里面呈现出来的。 这里我们可以观察到在 reCAPTCHA 的源码的最外层的 div 上面有一个字段,叫做 data-sitekey,这就是刚才我们所说的 sitekey,它是验证码的唯一标识,比如这里我先将这个 sitekey 保存下来,这里其值为:

1
6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-

下一步,我们就需要将这个 sitekey 和当前页面的 URL 告诉 2Captcha,让 2Captcha 来帮助我们识别这个 reCAPTCHA 验证码,告诉 2Captcha 之后,2Captcha 会利用这些信息加载出对应的验证码,再利用其背后的人力来对验证码进行识别,最后将识别得到的 token 返回给我们即可。 好,那么接下来怎么把这个信息告诉 2Captcha 呢? 很简单,2Captcha 为我们提供了一个接口,其接口地址为:https://2captcha.com/in.php,我们只需要将对应的信息发送到这个接口就好了。 那么发送需要什么参数呢,在这里介绍一下:

参数

类型

必须

描述

key

String

Yes

我们自己的 API KEY

method

String

Yes

userrecaptcha,定义破解 reCAPTCHA 验证码的方式

googlekey

String

Yes

reCAPTCHA 的 sitekey

pageurl

String

Yes

reCAPTCHA 当前所在的 URL

invisible

Integer Default: 0

No

是否可见,1 代表是隐式验证码,0 代表普通验证码。

header_acao

Integer Default: 0

No

跨域访问配置

pingback

String

No

回调地址

json

Integer Default: 0

No

返回格式,1 代表返回 JSON 格式,0 代表纯文本,默认 0

soft_id

Integer

No

ID of software developer. Developers who integrated their software with 2captcha get reward: 10% of spendings of their software users.

proxy

String

No

代理配置

在这里我们可以构造一个 URL,它包括这几个参数:

  • key:注意这里的 KEY 换成你自己的 API KEY
  • method:直接赋值 userrecaptcha
  • googlekey:复制的 sitekey
  • pageurl:当前 URL
  • json:直接赋值 1,代表返回 JSON 格式

比如在这里我就构造了这个 URL,内容如下:

1
https://2captcha.com/in.php?key=c0ae5935d807c28f285e5cb16c676a48&method=userrecaptcha&googlekey=6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-&pageurl=https://www.google.com/recaptcha/api2/demo&json=1

这时候我们直接向这个 URL 发起一个 GET 请求即可。 我们可以直接在浏览器里面输入这个 URL,也可以使用 requests 等请求库来完成:

1
2
3
4
import requests

response = requests.get(url)
print(response.json())

接口会返回如下格式的内容:

1
{'status': 1, 'request': '62919419695'}

这里它返回了一个 JSON 格式的数据,其中 status 代表请求状态,如果是 1 的话,代表请求成功,另外其还会包含一个 request 字段,其内容是一个 ID,这个 ID 就是识别这个验证码的任务的 ID。因为 2Captcha 背后有很多人来帮助识别验证码,所以 2Captcha 将每个验证码的识别划分为一个个任务,每个任务都有一个唯一的 ID,刚分配任务时,这个任务被标记为 NOT_READY 状态。这些任务接下来会被分发给一个个人,识别完成之后,该任务就会被标记为已经识别状态,同时附有识别之后的信息,如 token 等内容。 好,刚才的接口请求成功之后,这个 reCAPTCHA 的识别任务就已经被下发了,其背后会有对应的人来对这个 reCAPTCHA 验证码进行识别,识别过程可能需要十几秒到几十秒不等,我们可以通过另一个接口来获取任务的结果。 获取结果的接口地址为:https://2captcha.com/res.php,同样我们需要传入一些参数,其参数介绍如下

参数

类型

必需

描述

key

String

Yes

API KEY

action

String

Yes

get,表示获取验证码的结果

id

Integer

Yes

任务 ID,就是刚才 in.php 接口返回的结果。

json

Integer Default: 0

No

返回 JSON 格式,1 代表使用 JSON 格式,0 代表纯文本格式

在这里我们构造一个 URL,它包括如上的参数:

  • key:在这里换成你的 API KEY
  • action:就直接赋值 get
  • id:任务 ID
  • json:在这里用 1,即返回 JSON 格式数据

这样我们就构造了如下的 URL:

1
https://2captcha.com/res.php?key=c0ae5935d807c28f285e5cb16c676a48&action=get&id=62919419695&json=1

同样我们可以在浏览器中访问或者用 requests 请求,得到如下结果:

1
2
3
4
{
"status": 1,
"request": "03AOLTBLSg0fQUUMtP2o3kvJWNm6zla8MEjP_vPh629-xS-d_QrlJwMcxQfSJMUIU92noqbJ16yt5a0PdB3ORW-5MEbqK7NZ82bTnSZohCG_mYVVv8TbuvM1A99DFvlepxGEKlGCoi5lTHJd5z_QQ2mV1trGlI8VJkHjVAhLZzlz67MVgQzIu7aDl39n6aocAIVueQuCyjmA1C3hUECxpNlXJuXYVD10eJrqY_Bu36_2E0uBrmDIkAIjxCzEZWgadToU4ByLReOrNJ7_4t-P8leTUbPC5YBXvoDZZZByz8-vNnHzUu3GNNESzGSCMFfVPYumnXXI6i7TO5p1k-AElgb7qor6vDJGA_RpNNSUgAj8B0synG9APpbMQ4cEprHXle5pJtNCBX_v_8uqJLobomIx0St5l_H1tHGuTgI2UU-nWmR9TwvKp6SR-6G2Fi6pv7c8350tPbqJWWMcV0AXdfM85GjRDh2t7wh1CMukLQE21aIIwHh88kR0Fh0481Kw_umw8IfFCHyHKu8IcTERUL5LZdDzQkiGdF1wqWP-GhySMXEx-eOT7tB6SqPEAmO_mbwtJtA-qKzcHP"
}

如果其返回的是如上格式的数据,就代表 reCAPTCHA 验证码已经识别成功了,其返回的 request 字段的内容就是识别的 token,我们直接拿着这个 token 放到表单里面提交就成功了。 那这个 token 怎么来用呢? 其实如果不走 2Captcha 接口,我们如果人工验证成功之后,在其表单里面会把一个 name 叫做 g-recaptcha-response 的 textarea 赋值,如果验证成功,它的 value 值就是验证之后得到的 token,这个会作为表单提交的一部分发送到服务器进行验证。如果这个字段校验成功了,那就没问题了。 所以,2Captcha 相当于为我们模拟了点选验证码的过程,其最终得到的这个 token 其实就是我们应该赋值给 name 为 g-recaptcha-response 的内容。 那么怎么赋值呢? 很简单,用 JavaScript 就好了。我们可以用 JavaScript 选取到这个 textarea,然后直接赋值即可,代码如下:

1
document.getElementById("g-recaptcha-response").innerHTML="TOKEN_FROM_2CAPTCHA";

注意这里的 TOKEN_FROM_2CAPTCHA 需要换成刚才我们所得到的 token 值。我们做爬虫模拟登录的时候,假如是用 Selenium、Puppeteer 等软件,在模拟程序里面,只需要模拟执行这段 JavaScript 代码,就可以成功赋值了。 执行之后,直接提交表单,我们查看下 Network 请求: 可以看到其就是提交了一个表单,其中有一个字段就是 g-recaptcha-response,它会发送到服务端进行校验,校验通过,那就成功了。 所以,如果我们借助于 2Captcha 得到了这个 token,然后把它赋值到表单的 textarea 里面,表单就会提交,如果 token 有效,就能成功绕过登录,而不需要我们再去点选验证码了。 最后我们得到如下成功的页面: 至此,我们就成功地借助 2Captcha 来完成了 reCAPTCHA V2 验证码的识别。

总结

本节我们介绍了利用 2Captcha 来帮助识别 reCAPTCHA V2 的流程。那应该来讲,我觉得工程师使用这样的服务并不是一种令人羞耻的过程,尤其是他可以以比较低的价格实现你的需求的情况下。毕竟你的时间,本身就是一种价值。 最后 2Captcha 这个网站我放在下面,有感兴趣的朋友可以看一下。另外如果有什么爬虫方面想看的文章,也欢迎在下面留言,我们会挑选被点赞较多的主题尽快写文。谢谢!

2captcha.com/zh

Python

内容选自即将出版的《Python3 反爬虫原理与绕过实战》,本次公开书稿范围为第 6 章——文本混淆反爬虫。本篇为第 6 章中的第 4 小节,其余小节将逐步放送

字体反爬虫开篇概述

在 CSS3 之前,Web 开发者必须使用用户计算机上已有的字体。但是在 CSS3 时代,开发者可以使用@font-face 为网页指定字体,对用户计算机字体的依赖。开发者可将心仪的字体文件放在 Web 服务器上,并在 CSS 样式中使用它。用户使用浏览器访问 Web 应用时,对应的字体会被浏览器下载到用户的计算机上。 在学习浏览器和页面渲染的相关知识时,我们了解到 CSS 的作用是修饰 HTML ,所以在页面渲染的时候不会改变 HTML 文档内容。由于字体的加载和映射工作是由 CSS 完成的,所以即使我们借助 Splash、Selenium 和 Puppeteer 工具也无法获得对应的文字内容。字体反爬虫正是利用了这个特点,将自定义字体应用到网页中重要的数据上,使得爬虫程序无法获得正确的数据。

6.4.1 字体反爬虫示例

示例 7:字体反爬虫示例。 网址:http://www.porters.vip/confusion/movie.html。 任务:爬取影片信息展示页中的影片评分、评价人数和票房数据,页面内容如图 6-32 所示。 图 6-32 示例 7 页面 在编写代码之前,我们需要确定目标数据的元素定位。定位时,我们在 HTML 中发现了一些奇怪的符号,HTML 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div class="movie-index"> 
<p class="movie-index-title">用户评分</p>
<div class="movie-index-content score normal-score">
<span class="index-left info-num ">
<span class="stonefont"> ☒.☒ </span>
</span>
<div class="index-right">
<div class="star-wrapper">
<div class="star-on" style="width:90%;"></div>
</div>
<span class="score-num"><span class="stonefont"> ☒☒. ☒☒ 万</span>人评分</span>
</div>
</div>
</div>

页面中重要的数据都是一些奇怪的字符,本应该显示“9.7”的地方在 HTML 中显示的是“☒.☒”,而本应该显示“56.83”的地方在 HTML 中显示的是“☒☒.☒☒”。与 6.3 节中的映射反爬虫不同,案例中的文字都被“☒”符号代替了,根本无法分辨。这就很奇怪了,“☒”能代表这么多种数字吗? 要注意的是,Chrome 开发者工具的元素面板中显示的内容不一定是相应正文的原文,要想知道“☒”符号是什么,还需要到网页源代码中确认。对应的网页源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div class="movie-index">
<p class="movie-index-title">用户评分</p>
<div class="movie-index-content score normal-score">
<span class="index-left info-num ">
<span class="stonefont">&#xe624.&#xe9c7</span>
</span>
<div class="index-right">
<div class="star-wrapper">
<div class="star-on" style="width:90%;"></div>
</div>
<span class="score-num"><span class="stonefont">&#xf593&#xe9c7&#xe9c7.&#xe624万</span>人评分</span>
</div>
</div>
</div>

从网页源代码中看到的并不是符号,而是由&#x 开头的一些字符,这与示例 6 中的 SVG 映射反爬虫非常相似。我们将页面显示的数字与网页源代码中的字符进行比较,映射关系如图 6-33 所示。 图 6-33 字符与数字的映射关系 字符与数字是一一对应的,我们只需要多找一些页面,将 0 ~ 9 数字对应的字符凑齐即可。但如果目标网站的字体是动态变化的呢?映射关系也是变化的呢? 根据 6.3 节的学习和分析,我们知道人为映射并不能解决这些问题,必须找到映射关系的规律,并使用 Python 代码实现映射算法才行。继续往下分析,难道字符映射是先异步加载数据再使用 JavaScript 渲染的? 图 6-34 请求记录 网络请求记录如图 6-34 所示,请求记录中并没有发现异步请求,这个猜测并没有得到证实。CSS 样式方面有没有线索呢?页面中包裹符号的标签的 class 属性值都是 stonefont:

1
2
3
<span class="stonefont">&#xe624.&#xe9c7</span> 
<span class="stonefont">&#xf593&#xe9c7&#xe9c7.&#xe624 万</span>
<span class="stonefont">&#xea16&#xe339.&#xefd4&#xf19a</span>

但对应的 CSS 样式中仅设置了字体:

1
2
3
.stonefont { 
font-family: stonefont;
}

既然是自定义字体,就意味着会加载字体文件,我们可以在网络请求中找到加载的字体文件 movie.woff,并将其下载到本地,接着使用百度字体编辑器看一看里面的内容。 百度字体编辑器 FontEditor (详见 http://fontstore.baidu.com/static/editor/index.html)是一款在线字体编辑软件,能够打开本地或者远程的 ttf、woff、eot、otf 格式的字体文件,具备这些格式字体文件的导入和导出功能,并且提供字形编辑、轮廓编辑和字体实时预览功能,界面如图 6-35 所示。 图 6-35 百度字体编辑器界面 打开页面后,将 movie.woff 文件拖曳到百度字体编辑器的灰色区域即可,字体文件内容如图 6-36 所示。 图 6-36 字体文件 movie.woff 预览 该字体文件中共有 12 个字体块,其中包括 2 个空白字体块和 0 ~ 9 的数字字体块。我们可以大胆地猜测,评分数据和票房数据中使用的数字正是从此而来。 由此看来,我们还需要了解一些字体文件格式相关的知识,在了解文件格式和规律后,才能够找到更合理的解决办法。

6.4.2 字体文件 WOFF

WOFF(Web Open Font Format,Web 开放字体格式)是一种网页所采用的字体格式标准。本质上基于 SFNT 字体(如 TrueType),所以它具备 TrueType 的字体结构,我们只需要了解 TrueType 字体的相关知识即可。 TrueType 字体是苹果公司与微软公司联合开发的一种计算机轮廓字体,TrueType 字体中的每个字形由网格上的一系列点描述,点是字体中的最小单位,字形与点的关系如图 6-37 所示。 图 6-37 字形与点的关系 字体文件中不仅包含字形数据和点信息,还包括字符到字形映射、字体标题、命名和水平指标等,这些信息存在对应的表中,所以我们也可以认为 TrueType 字体文件由一系列的表组成,其中常用的表 及其作用如图 6-38 所示。 图 6-38 构成字体文件的常用表及其作用 如何查看这些表的结构和所包含的信息呢?我们可以借助第三方 Python 库 fonttools 将 WOFF 等字体文件转换成 XML 文件,这样就能查看字体文件的结构和表信息了。首先我们要安装 fonttools 库, 安装命令为:

1
$ pip install fonttools

安装完毕后就可以利用该库转换文件类型,对应的 Python 代码为:

1
2
3
from fontTools.ttLib import TTFont 
font = TTFont('movie.woff') # 打开当前目录的 movie.woff 文件
font.saveXML('movie.xml') # 另存为 movie.xml

代码运行后就会在当前目录生成名为 movie 的 XML 文件。文件中字符到字形映射表 cmap 的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<cmap_format_4 platformID="0" platEncID="3" language="0"> 
<map code="0x78" name="x"/>
<map code="0xe339" name="uniE339"/>
<map code="0xe624" name="uniE624"/>
<map code="0xe7df" name="uniE7DF"/>
<map code="0xe9c7" name="uniE9C7"/>
<map code="0xea16" name="uniEA16"/>
<map code="0xee76" name="uniEE76"/>
<map code="0xefd4" name="uniEFD4"/>
<map code="0xf19a" name="uniF19A"/>
<map code="0xf57b" name="uniF57B"/>
<map code="0xf593" name="uniF593"/>
</cmap_format_4>

map 标签中的 code 代表字符,name 代表字形名称,关系如图 6-39 所示。 图 6-39 字符到字形映射关系示例 XML 中的字符 0xe339 与网页源代码中的字符 对应,这样我们就确定了 HTML 中的字符码与 movie.woff 字体文件中对应的字形关系。字形数据存储在 glyf 表中,每个字形的数据都是独立的,例如字形 uniE339 的字形数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<TTGlyph name="uniE339" xMin="0" yMin="-12" xMax="510" yMax="719"> 
<contour>
<pt x="410" y="534" on="1"/>
<pt x="398" y="586" on="0"/>
<pt x="377" y="609" on="1"/>
<pt x="341" y="646" on="0"/>
<pt x="289" y="646" on="1"/>
...
</contour>
<contour>
<pt x="139" y="232" on="1"/>
<pt x="139" y="188" on="0"/>
<pt x="178" y="103" on="0"/>
...
</contour>
<instructions/>
</TTGlyph>

TTGlyph 标签中记录着字形的名称、x 轴坐标和 y 轴坐标(坐标也可以理解为字形的宽高)。contour 标签记录的是字形的轮廓信息,也就是多个点的坐标位置,正是这些点构成了如图 6-40 所示的字形。 图 6-40 字形 uniE339 的轮廓 我们可以在百度字体编辑器中调整点的位置,然后保存字体文件并将新字体文件转换为 XML 格式,相同名称的字形数据如下:

1
2
3
4
5
6
7
8
9
10
11
<TTGlyph name="uniE339" xMin="115" yMin="6" xMax="430" yMax="495"> 
<contour>
<pt x="400" y="352" on="1"/>
<pt x="356" y="406" on="0"/>
<pt x="342" y="421" on="1"/>
<pt x="318" y="446" on="0"/>
<pt x="283" y="446" on="1"/>
...
</contour>
<instructions/>
</TTGlyph>

接着将调整前的字形数据和调整后的字形数据进行对比。 如图 6-41 所示,点的位置调整后,字形数据也会发生相应的变化,如 xMin、xMax、yMin、yMax 还有 pt 标签中的 x 坐标 y 坐标都与之前的不同了。 图 6-41 字形数据对比 XML 文件中记录的是字形坐标信息,实际上,我们没有办法直接通过字形数据获得文字,只能从其他方面想办法。虽然目标网站使用多套字体,但相同文字的字形也是相同的。比如现在有 movie.woff 和 food.woff 这两套字体,它们包含的字形如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# movie.woff 
# 包含 10 个字形数据:[0123456789]
<cmap_format_4 platformID="0" platEncID="3" language="0">
<map code="0x78" name="x"/>
<map code="0xe339" name="uniE339"/> # 数字 6
<map code="0xe624" name="uniE624"/> # 数字 9
<map code="0xe7df" name="uniE7DF"/> # 数字 2
<map code="0xe9c7" name="uniE9C7"/> # 数字 7
<map code="0xea16" name="uniEA16"/> # 数字 5
<map code="0xee76" name="uniEE76"/> # 数字 0
<map code="0xefd4" name="uniEFD4"/> # 数字 8
<map code="0xf19a" name="uniF19A"/> # 数字 3
<map code="0xf57b" name="uniF57B"/> # 数字 1
<map code="0xf593" name="uniF593"/> # 数字 4
</cmap_format_4>

# food.woff
# 包含 3 个字形数据:[012]
<cmap_format_4 platformID="0" platEncID="3" language="0">
<map code="0x78" name="x"/>
<map code="0xe556" name="uniE556"/> # 数字 0
<map code="0xe667" name="uniE667"/> # 数字 1
<map code="0xe778" name="uniE778"/> # 数字 2
</cmap_format_4>

要实现自动识别文字,需要先准备参照字形,也就是人为地准备数字 0 ~ 9 的字形映射关系和字形数据,如:

1
2
3
4
5
# 0 和 7 与字形名称的映射伪代码,data 键对应的值是字形数据
font_mapping = [
{'name': 'uniE9C7', 'words': '7', 'data': 'uniE9C7_contour_pt'},
{'name': 'uniEE76', 'words': '0', 'data': 'uniEE76_countr_pt'},
]

当我们遇到目标网站上其他字体文件时,就可以使用参照字形中的字形数据与目标字形进行匹配,如果字形数据非常接近,就认为这两个字形描述的是相同的文字。字形数据包含记录字形名称和字形起止坐标的 TTGlyph 标签以及记录点坐标的 pt 标签,起止坐标代表的是字形在画布上的位置,点坐标代表字形中每个点在画布上的位置。在起止坐标中,x 轴差值代表字形宽度,y 轴差值代表字形高度。 如图 6-42 所示,两个字形的起止坐标和宽高都有很大的差别,但是却能够描述相同的文字,所以字形在画布中的位置并不会影响描述的文字,字形宽度和字形高度也不会影响描述的文字。 图 6-42 描述相同文字的两个字形 点坐标的数量和坐标值可以作为比较条件吗? 如图 6-43 所示,两个不同文字的字形数据是不一样的。虽然这两种字形的 name 都是 uniE9C7,但是字形数据中大部分 pt 标签 x 和 y 的差距都很大,所以我们可以判定这两个字形描述的并不是 同一个文字。你可能会想到点的数量也可以作为排除条件,也就是说如果点的数量不相同,那么这个 两个字形描述的就不是同一个文字。真的是这样吗? 图 6-43 描述不同文字的字形数据对比 在图 6-44 中,左侧描述文字 7 的字形有 17 个点,而右侧描述文字 7 的字形却有 20 个点。对应的字形信息如图 6-45 所示。 图 6-44 描述相同文字的字形 图 6-45 描述相同文字的字形信息 虽然点的数量不一样,但是它们的字形并没有太大的变化,也不会造成用户误读,所以点的数量并不能作为排除不同字形的条件。因此,只有起止坐标和点坐标数据完全相同的字形,描述的才是相同字符。

6.4.3 字体反爬虫绕过实战

要确定两组字形数据描述的是否为相同字符,我们必须取出 HTML 中对应的字形数据,然后将待确认的字形与我们准备好的基准字形数据进行对比。现在我们来整理一下这一系列工作的步骤。 (1) 准备基准字形描述信息。 (2) 访问目标网页。 (3) 从目标网页中读取字体编码字符。 (4) 下载 WOFF 文件并用 Python 代码打开。 (5) 根据字体编码字符找到 WOFF 文件中的字形轮廓信息。 (6) 将该字形轮廓信息与基准字形轮廓信息进行对比。 (7) 得出对比结果。 我们先完成前 4 个步骤的代码。下载 WOFF 文件并将其中字形描述的文字与人类认知的文字进行映射。由于字形数据比较庞大,所以我们可以将字形数据进行散列计算,这样得到的结果既简短又唯一,不会影响对比结果。这里以数字 0 ~ 9 为例:

1
2
3
4
5
6
7
8
9
10
11
12
base_font = { 
"font": [{"name": "uniEE76", "value": "0", "hex": "fc170db1563e66547e9100cf7784951f"},
{"name": "uniF57B", "value": "1", "hex": "251357942c5160a003eec31c68a06f64"},
{"name": "uniE7DF", "value": "2", "hex": "8a3ab2e9ca7db2b13ce198521010bde4"},
{"name": "uniF19A", "value": "3", "hex": "712e4b5abd0ba2b09aff19be89e75146"},
{"name": "uniF593", "value": "4", "hex": "e5764c45cf9de7f0a4ada6b0370b81a1"},
{"name": "uniEA16", "value": "5", "hex": "c631abb5e408146eb1a17db4113f878f"},
{"name": "uniE339", "value": "6", "hex": "0833d3b4f61f02258217421b4e4bde24"},
{"name": "uniE9C7", "value": "7", "hex": "4aa5ac9a6741107dca4c5dd05176ec4c"},
{"name": "uniEFD4", "value": "8", "hex": "c37e95c05e0dd147b47f3cb1e5ac60d7"},
{"name": "uniE624", "value": "9", "hex": "704362b6e0feb6cd0b1303f10c000f95"}]
}

字典中的 name 代表该字形的名称,value 代表该字形描述的文字,hex 代表字形信息的 MD5 值。 考虑到网络请求记录中的字体文件路径有可能会变化,我们必须找到 CSS 中设定的字体文件路径,引入 CSS 的 HTML 代码为:

1
<link href="./css/movie.css" rel="stylesheet">

由引入代码得知该 CSS 文件的路径为 http://www.porters.vip/confusion/css/movie.css,文件中 @font-face 处就是设置字体的代码:

1
2
3
4
@font-face { 
font-family: stonefont;
src:url('../font/movie.woff') format('woff');
}

字体文件路径为 http://www.porters.vip/confusion/font/movie.woff。找到文件后,我们就可以开始编写代码了,对应的 Python 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import re 
from parsel import Selector
from urllib import parse
from fontTools.ttLib import TTFont
url = 'http://www.porters.vip/confusion/movie.html'
resp = requests.get(url)
sel = Selector(resp.text)
# 提取页面加载的所有 css 文件路径
css_path = sel.css('link[rel=stylesheet]::attr(href)').extract()
woffs = []
for c in css_path:
# 拼接正确的 css 文件路径
css_url = parse.urljoin(url, c)
# 向 css 文件发起请求
css_resp = requests.get(css_url)
# 匹配 css 文件中的 woff 文件路径
woff_path = re.findall("src:url('..(.*.woff)') format('woff');",
css_resp.text)
if woff_path:
# 如故路径存在则添加到 woffs 列表中
woffs += woff_path
woff_url = 'http://www.porters.vip/confusion' + woffs.pop()
woff = requests.get(woff_url)
filename = 'target.woff'
with open(filename, 'wb') as f:
# 将文件保存到本地
f.write(woff.content)
# 使用 TTFont 库打开刚才下载的 woff 文件
font = TTFont(filename)

因为 TTFont 可以直接读取 woff 文件的结构,所以这里不需要将 woff 保存为 XML 文件。接着以评分数据 9.7 对应的编码 #xe624.#xe9c7 进行测试,在原来的代码中引入基准字体数据 base_font,然后新增以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
web_code = '&#xe624.&#xe9c7'
# 编码文字替换
woff_code = [i.upper().replace('&#X', 'uni') for i in web_code.split('.')]
import hashlib
result = []
for w in woff_code:
# 从字体文件中取出对应编码的字形信息
content = font['glyf'].glyphs.get(w).data
# 字形信息 MD5
glyph = hashlib.md5(content).hexdigest()
for b in base_font.get('font'):
# 与基准字形中的 MD5 值进行对比,如果相同则取出该字形描述的文字
if b.get('hex') == glyph:
result.append(b.get('value'))
break
# 打印映射结果
print(result)

以上代码运行结果为:

1
['9', '7']

运行结果说明能够正确映射字体文件中字形描述的文字。

6.4.4 小结

字体反爬能给爬虫工程师带来很大的麻烦。虽然爬虫工程师找到了应对方法,但这种方法依赖的条件比较严苛,如果开发者频繁改动字体文件或准备多套字体文件并随机切换,那真是一件令爬虫工程师头疼的事。不过,这些工作对于开发者来说也不是轻松的事。

新书福利

真是翘首以盼!《Python3 反爬虫原理与绕过实战》一书终于要跟大家见面了!为了感谢大家对韦世东和本书的期待与支持,在新书发布时会举办多场送书活动和限时折扣活动。 想要与作者韦世东交流或者参加新书发布活动的朋友可以扫描二维码进群与我互动哦!

转载说明

本篇内容摘自出版图书《Python3 反爬虫原理与绕过实战》,欢迎各位好友与同行转载! 记得带上相关的版权信息哦😊。

技术杂谈

在很多情况下,我们可能想要在网页中自动执行某些代码,帮助我们完成一些操作。如自动抢票、自动刷单、自动爬虫等等,这些操作绝大部分都是借助 JavaScript 来实现的。那么问题来了?在浏览器里面怎样才能方便地执行我们所期望执行的 JavaScript 代码呢?在这里推荐一个插件,叫做 Tampermonkey。这个插件的功能非常强大,利用它我们几乎可以在网页中执行任何 JavaScript 代码,实现我们想要的功能。 当然不仅仅是自动抢票、自动刷单、自动爬虫,Tampermonkey 的用途远远不止这些,只要我们想要的功能能用 JavaScript 实现,Tampermonkey 就可以帮我们做到。比如我们可以将 Tampermonkey 应用到 JavaScript 逆向分析中,去帮助我们更方便地分析一些 JavaScript 加密和混淆代码。 本节我们就来介绍一下这个插件的使用方法,并结合一个实际案例,介绍下这个插件在 JavaScript 逆向分析中的用途。

Tampermonkey

Tampermonkey,中文也叫作「油猴」,它是一款浏览器插件,支持 Chrome。利用它我们可以在浏览器加载页面时自动执行某些 JavaScript 脚本。由于执行的是 JavaScript,所以我们几乎可以在网页中完成任何我们想实现的效果,如自动爬虫、自动修改页面、自动响应事件等等。

安装

首先我们需要安装 Tampermonkey,这里我们使用的浏览器是 Chrome。直接在 Chrome 应用商店或者在 Tampermonkey 的官网 https://www.tampermonkey.net/ 下载安装即可。 安装完成之后,在 Chrome 浏览器的右上角会出现 Tampermonkey 的图标,这就代表安装成功了。

获取脚本

Tampermonkey 运行的是 JavaScript 脚本,每个网站都能有对应的脚本运行,不同的脚本能完成不同的功能。这些脚本我们可以自定义,同样也可以用已经写好的很多脚本,毕竟有些轮子有了,我们就不需要再去造了。 我们可以在 https://greasyfork.org/zh-CN/scripts 这个网站找到一些非常实用的脚本,如全网视频去广告、百度云全网搜索等等,大家可以体验一下。

脚本编写

除了使用别人已经写好的脚本,我们也可以自己编写脚本来实现想要的功能。编写脚本难不难呢?其实就是写 JavaScript 代码,只要懂一些 JavaScript 的语法就好了。另外除了懂 JavaScript 语法,我们还需要遵循脚本的一些写作规范,这其中就包括一些参数的设置。 下面我们就简单实现一个小的脚本,实现某个功能。 首先我们可以点击 Tampermonkey 插件图标,点击「管理面板」按钮,打开脚本管理页面。 界面类似显示如下图所示。 在这里显示了我们已经有的一些 Tampermonkey 脚本,包括我们自行创建的,也包括从第三方网站下载安装的。 另外这里也提供了编辑、调试、删除等管理功能,我们可以方便地对脚本进行管理。 接下来我们来创建一个新的脚本来试试,点击左侧的「+」号,会显示如图所示的页面。 初始化的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ==UserScript==
// @name New Userscript
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://www.tampermonkey.net/documentation.php?ext=dhdg
// @grant none
// ==/UserScript==

(function() {
'use strict';

// Your code here...
})();

这里最上面是一些注释,但这些注释是非常有用的,这部分内容叫做 UserScript Header ,我们可以在里面配置一些脚本的信息,如名称、版本、描述、生效站点等等。 下面简单介绍下 UserScript Header 的一些参数定义。

  • @name:脚本的名称,就是在控制面板显示的脚本名称。
  • @namespace:脚本的命名空间。
  • @version:脚本的版本,主要是做版本更新时用。
  • @author:作者。
  • @description:脚本描述。
  • @homepage, @homepageURL, @website,@source:作者主页,用于在Tampermonkey选项页面上从脚本名称点击跳转。请注意,如果 @namespace 标记以 http://开头,此处也要一样。
  • @icon, @iconURL and @defaulticon:低分辨率图标。
  • @icon64 and @icon64URL:64x64 高分辨率图标。
  • @updateURL:检查更新的网址,需要定义 @version。
  • @downloadURL:更新下载脚本的网址,如果定义成 none 就不会检查更新。
  • @supportURL:报告问题的网址。
  • @include:生效页面,可以配置多个,但注意这里并不支持 URL Hash。 例如:

    1
    2
    3
    4
    // @include http://www.tampermonkey.net/*
    // @include http://*
    // @include https://*
    // @include *
  • @match:约等于 @include 标签,可以配置多个。

  • @exclude:不生效页面,可配置多个,优先级高于 @include 和 @match。
  • @require:附加脚本网址,相当于引入外部的脚本,这些脚本会在自定义脚本执行之前执行,比如引入一些必须的库,如 jQuery 等,这里可以支持配置多个 @require 参数。 例如:

    1
    2
    3
    // @require https://code.jquery.com/jquery-2.1.4.min.js
    // @require https://code.jquery.com/jquery-2.1.3.min.js#sha256=23456...
    // @require https://code.jquery.com/jquery-2.1.2.min.js#md5=34567...,sha256=6789...
  • @resource:预加载资源,可通过 GM_getResourceURL 和 GM_getResourceText 读取。

  • @connect:允许被 GM_xmlhttpRequest 访问的域名,每行一个。
  • @run-at:脚本注入的时刻,如页面刚加载时,某个事件发生后等等。 例如:
    • document-start:尽可能地早执行此脚本。
    • document-body:DOM 的 body 出现时执行。
    • document-end:DOMContentLoaded 事件发生时或发生后后执行。
    • document-idle:DOMContentLoaded 事件发生后执行,即 DOM 加载完成之后执行,这是默认的选项。
    • context-menu:如果在浏览器上下文菜单(仅限桌面 Chrome 浏览器)中点击该脚本,则会注入该脚本。注意:如果使用此值,则将忽略所有 @include 和 @exclude 语句。
  • @grant:用于添加 GM 函数到白名单,相当于授权某些 GM 函数的使用权限。 例如:

    1
    2
    3
    4
    5
    6
    // @grant GM_setValue
    // @grant GM_getValue
    // @grant GM_setClipboard
    // @grant unsafeWindow
    // @grant window.close
    // @grant window.focus

    如果没有定义过 @grant 选项,Tampermonkey 会猜测所需要的函数使用情况。

  • @noframes:此标记使脚本在主页面上运行,但不会在 iframe 上运行。
  • @nocompat:由于部分代码可能是专门为专门的浏览器所写,通过此标记,Tampermonkey 会知道脚本可以运行的浏览器。 例如:

    1
    // @nocompat Chrome

    这样就指定了脚本只在 Chrome 浏览器中运行。

除此之外,Tampermonkey 还定义了一些 API,使得我们可以方便地完成某个操作,如:

  • GM_log:将日志输出到控制台。
  • GM_setValue:将参数内容保存到 Storage 中。
  • GM_addValueChangeListener:为某个变量添加监听,当这个变量的值改变时,就会触发回调。
  • GM_xmlhttpRequest:发起 Ajax 请求。
  • GM_download:下载某个文件到磁盘。
  • GM_setClipboard:将某个内容保存到粘贴板。

还有很多其他的 API,大家可以到 https://www.tampermonkey.net/documentation.php 来查看更多的内容。 在 UserScript Header 下方是 JavaScript 函数和调用的代码,其中 'use strict' 标明代码使用 JavaScript 的严格模式,在严格模式下可以消除 Javascript 语法的一些不合理、不严谨之处,减少一些怪异行为,如不能直接使用未声明的变量,这样可以保证代码的运行安全,同时提高编译器的效率,提高运行速度。在下方 // Your code here... 这里我们就可以编写自己的代码了。

实战 JavaScript 逆向

下面我们来通过一个简单的 JavaScript 逆向案例来演示一下 Tampermonkey 的作用。 在 JavaScript 逆向的时候,我们经常会需要追踪某些方法的堆栈调用情况,但很多情况下,一些 JavaScript 的变量或者方法名经过混淆之后是非常难以捕捉的。 但如果我们能掌握一定的门路或规律,辅助以 Tampermonkey,就可以更轻松地找出一些 JavaScript 方法的断点位置,从而加速逆向过程。 在逆向过程中,一个非常典型的技术就是 Hook 技术。Hook 技术中文又叫做钩子技术,它就是在程序运行的过程中,对其中的某个方法进行重写,在原先的方法前后加入我们自定义的代码。相当于在系统没有调用该函数之前,钩子程序就先捕获该消息,钩子函数先得到控制权,这时钩子函数既可以加工处理(改变)该函数的执行行为,还可以强制结束消息的传递。 如果觉得比较抽象,看完下面的 Hook 案例就懂了。 例如,我们接下来使用 Tampermonkey 实现对某个 JavaScript 方法的 Hook,轻松找到某个方法执行的位置,从而快速定位到逆向入口。 接下来我们来这么一个简单的网站:https://scrape.cuiqingcai.com/login1,这个网站结构非常简单,就是一个用户名密码登录,但是不同的是,点击 Submit 的时候,表单提交 POST 的内容并不是单纯的用户名和密码,而是一个加密后的 Token。 页面长这样: 我们随便输入用户名密码,点击登录按钮,观察一下网络请求的变化。 可以看到如下结果: 看到实际上控制台提交了一个 POST 请求,内容为:

1
{"token":"eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiJhZG1pbiJ9"}

嗯,确实,没有诸如 username 和 password 的内容了,那这究竟是个啥?我要是做爬虫的话?怎么模拟登录呢? 模拟登录的前提当然就是找到当前 token 生成的逻辑了,那么问题来了,到底这个 token 和用户名、密码什么关系呢?我们怎么来找寻其中的蛛丝马迹呢? 这里我们就可能思考了,本身输入的是用户名和密码,但是提交的时候却变成了一个 token,经过观察 token 的内容还很像 Base64 编码。这就代表,网站可能首先将用户名密码混为了一个新的字符串,然后最后经过了一次 Base64 编码,最后将其赋值为 token 来提交了。所以,初步观察我们可以得出这么多信息。 好,那就来验证下吧,看看网站 JavaScript 代码里面是咋实现的。 接下来我们看看网站的源码,打开 Sources 面板,好家伙,看起来都是 Webpack 打包之后的内容,经过了一些混淆,类似结果如下: 这么多混淆代码,总不能一点点扒着看吧?这得找到猴年马月?那么遇到这种情形,这怎么去找 token 的生成位置呢? 解决方法其实有两种。

Ajax 断点

由于这个请求正好是一个 Ajax 请求,所以我们可以添加一个 XHR 断点监听,把 POST 的网址加到断点监听上面去,在 Sources 面板右侧添加这么一个 XHR 断点,如图所示: image-20191215011457030 这时候如果我们再次点击登录按钮的时候,正好发起一次 Ajax 请求,就进入到断点了,然后再看堆栈信息就可以一步步找到编码的入口了。 点击 Submit 之后,页面就进入了 Debug 状态停下来了,结果如下: image-20191215011734985 一步步找,我们最后其实可以找到入口其实是在 onSubmit 方法这里。但实际上,我们观察到,这里的断点的栈顶还会包括了一些 async Promise 等无关的内容,而我们真正想找的是用户名和密码经过处理,再进行 Base64 编码的地方,这些请求的调用实际上和我们找寻的入口是没有很大的关系的。 另外,如果我们想找的入口位置并不伴随这一次 Ajax 请求,这个方法就没法用了。 这个方法是奏效的,但是我们先不讲 onSubmit 方法里面究竟是什么逻辑,下一个方法再来讲。

Hook Function

所以,这里介绍第二种可以快速定位入口的方法,那就是使用 Tampermonkey 自定义 JavaScript 实现某个 JavaScript 方法的 Hook。Hook 哪里呢?最明显的,Hook Base64 编码的位置就好了。 那么这里就涉及到一个小知识点,JavaScript 里面的 Base64 编码是怎么实现的。没错就是 btoa 方法,所以说,我们来 Hook btoa 方法就好了。 好,这里我们新建一个 Tampermonkey 脚本,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ==UserScript==
// @name HookBase64
// @namespace https://scrape.cuiqingcai.com/
// @version 0.1
// @description Hook Base64 encode function
// @author Germey
// @match https://scrape.cuiqingcai.com/login1
// @grant none
// ==/UserScript==
(function () {
'use strict'
function hook(object, attr) {
var func = object[attr]
object[attr] = function () {
console.log('hooked', object, attr)
var ret = func.apply(object, arguments)
debugger
return ret
}
}
hook(window, 'btoa')
})()

首先我们定义了一些 UserScript Header,包括 @name、@match 等等,这里比较重要的就是 @name,表示脚本名称;另外一个就是 @match,代表脚本生效的网址。 脚本的内容如上所示。我们定义了一个 hook 方法,传入 object 和 attr 参数,意思就是 Hook object 对象的 attr 参数。例如我们如果想 Hook 一个 alert 方法,那就把 object 设置为 window,把 attr 设置为 alert 字符串。这里我们想要 Hook Base64 的编码方法,在 JavaScript 中,Based64 编码是用 btoa 方法实现的,那么这里我们就只需要 Hook window 对象的 btoa 方法就好了。 那么 Hook 是怎么实现的呢?我们来看下,首先一句 var func = object[attr],相当于我们先把它赋值为一个变量,我们调用 func 方法就可以实现和原来相同的功能。接着,我们再直接改写这个方法的定义,直接改写 object[attr],将其改写成一个新的方法,在新的方法中,通过 func.apply 方法又重新调用了原来的方法。这样我们就可以保证,前后方法的执行效果是不受什么影响的,之前这个方法该干啥就还是干啥的。但是和之前不同的是,我们自定义方法之后,现在可以在 func 方法执行的前后,再加入自己的代码,如 console.log 将信息输出到控制台,如 debugger 进入断点等等。这个过程中,我们先临时保存下来了 func 方法,然后定义一个新的方法,接管程序控制权,在其中自定义我们想要的实现,同时在新的方法里面再重新调回 func 方法,保证前后结果是不受影响的。所以,我们达到了在不影响原有方法效果的前提下,可以实现在方法的前后实现自定义的功能,就是 Hook 的过程。 最后,我们调用 hook 方法,传入 window 对象和 btoa 字符串,保存。 接下来刷新下页面,这时候我们就可以看到这个脚本就在当前页面生效了,如图所示。 接下来,打开控制台,切换到 Sources 面板,这时候我们可以看到站点下面的资源多了一个叫做 Tampermonkey 的目录,展开之后,发现就是我们刚刚自定义的脚本。 然后输入用户名、密码,点击提交。发现成功进入了断点模式停下来了,代码就卡在了我们自定义的 debugger 这一行代码的位置,如下图所示。 成功 Hook 住了,这说明 JavaScript 代码在执行过程中调用到了 btoa 方法。 看下控制台,如下图所示。 image-20191215014538625 这里也输出了 window 对象和 btoa 方法,验证正确。 这样,我们就顺利找到了 Base64 编码操作这个路口,然后看看堆栈信息,也已经不会出现 async、Promise 这样的调用,很清晰地呈现了 btoa 方法逐层调用的过程,非常清晰明了了,如图所示。 各个底层的 encode 方法略过,这样我们也非常顺利地找到了 onSubmit 方法里面的处理逻辑:

1
2
3
4
5
6
7
8
onSubmit: function() {
var e = c.encode(JSON.stringify(this.form));
this.$http.post(a["a"].state.url.root, {
token: e
}).then((function(e) {
console.log("data", e)
}))
}

仔细看看,encode 方法其实就是调用了一下 btoa 方法,就是一个 Base64 编码的过程。 另外堆栈信息中可以清晰地看到 Hook 的方法在执行前传入的参数值,即 arguments。另外执行的之后的结果 ret 也可以轻松地找到了,如图所示: 所以,现在我们知道了 token 和用户名、密码是什么关系了吧。 这里一目了然了,就是对表单进行了 JSON 序列化,然后调用了 encode 也就是 btoa 方法,并赋值为了 token,入口顺利解开。后面,我们只需要模拟这个过程就 OK 了。 所以,我们通过 Tampermonkey 自定义 JavaScript 脚本的方式实现了某个方法调用的 Hook,使得我们快速能定位到加密入口的位置,非常方便。 以后如果观察出来了一些门道,可以多使用这种方法来尝试,如 Hook encode 方法、decode 方法、stringify 方法、log 方法、alert 方法等等,简单而又高效。 以上便是通过 Tampermonkey 实现简单 Hook 的基础操作,当然这个仅仅是一个常见的基础案例,不过从中我们也可以总结出一些 Hook 的基本门道。 后面我们会继续介绍更多相关内容。

参考来源

注明

本篇属于 JavaScript 逆向系列内容。由于某些原因,JavaScript 逆向是在爬虫中比较敏感的内容,因此文章中不会选取当前市面上任何一个商业网站作为案例,都是通过自建平台示例的方式来单独讲解某个知识点。另外大家不要将相关技术应用到非法用途,惜命惜命。

Python

15.5 Gerapy 分布式管理

我们可以通过 Scrapyd-Client 将 Scrapy 项目部署到 Scrapyd 上,并且可以通过 Scrapyd API 来控制 Scrapy 的运行。那么,我们是否可以做到更优化?方法是否可以更方便可控? 我们重新分析一下当前可以优化的问题。

  • 使用 Scrapyd-Client 部署时,需要在配置文件中配置好各台主机的地址,然后利用命令行执行部署过程。如果我们省去各台主机的地址配置,将命令行对接图形界面,只需要点击按钮即可实现批量部署,这样就更方便了。
  • 使用 Scrapyd API 可以控制 Scrapy 任务的启动、终止等工作,但很多操作还是需要代码来实现,同时获取爬取日志还比较烦琐。如果我们有一个图形界面,只需要点击按钮即可启动和终止爬虫任务,同时还可以实时查看爬取日志报告,那这将大大节省我们的时间和精力。

所以我们的终极目标是如下内容。

  • 更方便地控制爬虫运行
  • 更直观地查看爬虫状态
  • 更实时地查看爬取结果
  • 更简单地实现项目部署
  • 更统一地实现主机管理

而这所有的工作均可通过 Gerapy 来实现。 Gerapy 是一个基于 Scrapyd、Scrapyd API、Django、Vue.js 搭建的分布式爬虫管理框架。接下来将简单介绍它的使用方法。

1. 准备工作

在本节开始之前请确保已经正确安装好了 Gerapy,安装方式可以参考第一章。

2. 使用说明

首先可以利用 gerapy 命令新建一个项目,命令如下:

1
gerapy init

这样会在当前目录下生成一个 gerapy 文件夹,然后进入 gerapy 文件夹,会发现一个空的 projects 文件夹,我们后文会提及。 这时先对数据库进行初始化:

1
gerapy migrate

这样即会生成一个 SQLite 数据库,数据库中会用于保存各个主机配置信息、部署版本等。 接下来启动 Gerapy 服务,命令如下:

1
gerapy runserver

这样即可在默认 8000 端口上开启 Gerapy 服务,我们浏览器打开:http://localhost:8000 即可进入 Gerapy 的管理页面,在这里提供了主机管理和项目管理的功能。 主机管理中,我们可以将各台主机的 Scrapyd 运行地址和端口添加,并加以名称标记,添加之后便会出现在主机列表中,Gerapy 会监控各台主机的运行状况并以不同的状态标识,如图 15-6 所示: 图 15-6 主机列表 另外刚才我们提到在 gerapy 目录下有一个空的 projects 文件夹,这就是存放 Scrapy 目录的文件夹,如果我们想要部署某个 Scrapy 项目,只需要将该项目文件放到 projects 文件夹下即可。 比如这里我放了两个 Scrapy 项目,如图 15-7 所示: 图 15-7 项目目录 这时重新回到 Gerapy 管理界面,点击项目管理,即可看到当前项目列表,如图 15-8 所示: 图 15-8 项目列表 由于此处我有过打包和部署记录,在这里分别予以显示。 Gerapy 提供了项目在线编辑功能,我们可以点击编辑即可可视化地对项目进行编辑,如图 15-9 所示: 图 15-9 可视化编辑 如果项目没有问题,可以点击部署进行打包和部署,部署之前需要打包项目,打包时可以指定版本描述,如图 15-10 所示: 图 15-10 项目打包 打包完成之后可以直接点击部署按钮即可将打包好的 Scrapy 项目部署到对应的云主机上,同时也可以批量部署,如图 15-11 所示: 图 15-11 部署页面 部署完毕之后就可以回到主机管理页面进行任务调度了,点击调度即可查看进入任务管理页面,可以当前主机所有任务的运行状态,如图 15-12 所示: 图 15-12 任务运行状态 我们可以通过点击新任务、停止等按钮来实现任务的启动和停止等操作,同时也可以通过展开任务条目查看日志详情,如图 15-13 所示: 图 15-13 查看日志 这样我们就可以实时查看到各个任务运行状态了。 以上便是 Gerapy 的一些功能的简单介绍,使用它我们可以更加方便地管理、部署和监控 Scrapy 项目,尤其是对分布式爬虫来说。 更多的信息可以查看 Gerapy 的 GitHub 地址:https://github.com/Gerapy

3. 结语

本节我们介绍了 Gerapy 的简单使用,利用它我们可以方便地实现 Scrapy 项目的部署、管理等操作,可以大大提高效率。

Python

15.4 Scrapyd 批量部署

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

1. 镜像部署

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

2. 模板部署

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

1
azure login -e AzureChinaCloud

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

1
azure group create myResourceGroup chinanorth

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

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

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

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

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

3. 结语

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

Python

15.3 Scrapyd 对接 Docker

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

1. 准备工作

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

2. 对接 Docker

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

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

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

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

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

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

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

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

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

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

1
docker build -t scrapyd:latest .

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

1
docker run -d -p 6800:6800 scrapyd

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

1
docker tag scrapyd:latest germey/scrapyd:latest

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

1
docker push germey/scrapyd:latest

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

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

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

3. 结语

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

Python

15.2 Scrapyd-Client 的使用

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

1. 准备工作

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

2. Scrapyd-Client 的功能

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

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

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

3. Scrapyd-Client 部署

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

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

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

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

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

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

1
scrapyd-deploy

运行结果如下:

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

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

1
scrapyd-deploy --version 201707131455

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

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

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

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

1
scrapyd-deploy vm2

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

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

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

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

4. 结语

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

Python

15.1 Scrapyd 分布式部署

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

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

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

1. 了解 Scrapyd

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

2. 准备工作

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

3. 访问 Scrapyd

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

4. Scrapyd 的功能

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

daemonstatus.json

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

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

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

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

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

addversion.json

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

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

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

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

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

schedule.json

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

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

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

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

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

cancel.json

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

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

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

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

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

listprojects.json

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

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

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

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

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

listversions.json

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

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

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

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

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

listspiders.json

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

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

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

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

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

listjobs.json

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

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

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

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

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

delversion.json

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

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

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

1
{"status": "ok"}

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

delproject.json

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

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

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

1
{"status": "ok"}

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

5. ScrapydAPI 的使用

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

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

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

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

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

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

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

6. 结语

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

Python

14.4 Bloom Filter 的对接

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

1. 了解 BloomFilter

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

2. BloomFilter 的算法

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

m/n

最优 k

k=1

k=2

k=3

k=4

k=5

k=6

k=7

k=8

2

1.39

0.393

0.400

3

2.08

0.283

0.237

0.253

4

2.77

0.221

0.155

0.147

0.160

5

3.46

0.181

0.109

0.092

0.092

0.101

6

4.16

0.154

0.0804

0.0609

0.0561

0.0578

0.0638

7

4.85

0.133

0.0618

0.0423

0.0359

0.0347

0.0364

8

5.55

0.118

0.0489

0.0306

0.024

0.0217

0.0216

0.0229

9

6.24

0.105

0.0397

0.0228

0.0166

0.0141

0.0133

0.0135

0.0145

10

6.93

0.0952

0.0329

0.0174

0.0118

0.00943

0.00844

0.00819

0.00846

11

7.62

0.0869

0.0276

0.0136

0.00864

0.0065

0.00552

0.00513

0.00509

12

8.32

0.08

0.0236

0.0108

0.00646

0.00459

0.00371

0.00329

0.00314

13

9.01

0.074

0.0203

0.00875

0.00492

0.00332

0.00255

0.00217

0.00199

14

9.7

0.0689

0.0177

0.00718

0.00381

0.00244

0.00179

0.00146

0.00129

15

10.4

0.0645

0.0156

0.00596

0.003

0.00183

0.00128

0.001

0.000852

16

11.1

0.0606

0.0138

0.005

0.00239

0.00139

0.000935

0.000702

0.000574

17

11.8

0.0571

0.0123

0.00423

0.00193

0.00107

0.000692

0.000499

0.000394

18

12.5

0.054

0.0111

0.00362

0.00158

0.000839

0.000519

0.00036

0.000275

19

13.2

0.0513

0.00998

0.00312

0.0013

0.000663

0.000394

0.000264

0.000194

20

13.9

0.0488

0.00906

0.0027

0.00108

0.00053

0.000303

0.000196

0.00014

21

14.6

0.0465

0.00825

0.00236

0.000905

0.000427

0.000236

0.000147

0.000101

22

15.2

0.0444

0.00755

0.00207

0.000764

0.000347

0.000185

0.000112

7.46e-05

23

15.9

0.0425

0.00694

0.00183

0.000649

0.000285

0.000147

8.56e-05

5.55e-05

24

16.6

0.0408

0.00639

0.00162

0.000555

0.000235

0.000117

6.63e-05

4.17e-05

25

17.3

0.0392

0.00591

0.00145

0.000478

0.000196

9.44e-05

5.18e-05

3.16e-05

26

18

0.0377

0.00548

0.00129

0.000413

0.000164

7.66e-05

4.08e-05

2.42e-05

27

18.7

0.0364

0.0051

0.00116

0.000359

0.000138

6.26e-05

3.24e-05

1.87e-05

28

19.4

0.0351

0.00475

0.00105

0.000314

0.000117

5.15e-05

2.59e-05

1.46e-05

29

20.1

0.0339

0.00444

0.000949

0.000276

9.96e-05

4.26e-05

2.09e-05

1.14e-05

30

20.8

0.0328

0.00416

0.000862

0.000243

8.53e-05

3.55e-05

1.69e-05

9.01e-06

31

21.5

0.0317

0.0039

0.000785

0.000215

7.33e-05

2.97e-05

1.38e-05

7.16e-06

32

22.2

0.0308

0.00367

0.000717

0.000191

6.33e-05

2.5e-05

1.13e-05

5.73e-06

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

3. 对接 Scrapy-Redis

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

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

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

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

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

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

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

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

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

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

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

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

1
2
True
False

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

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

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

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

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

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

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

1
2
BLOOMFILTER_HASH_NUMBER = 6
BLOOMFILTER_BIT = 30

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

4. 本节代码

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

5. 使用

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

1
pip3 install scrapy-redis-bloomfilter

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

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

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

6. 测试

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

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

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

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

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

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

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

1
scrapy crawl test

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

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

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

1
'bloomfilter/filtered': 10,

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

7. 结语

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

Python

14.3 Scrapy 分布式实现

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

1. 准备工作

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

2. 搭建 Redis 服务器

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

3. 部署代理池和 Cookies 池

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

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

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

4. 配置 Scrapy-Redis

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

核心配置

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

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

Redis 连接配置

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

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

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

1
redis://:foobared@120.27.34.25:6379

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

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

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

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

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

配置调度队列

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

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

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

配置持久化

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

1
SCHEDULER_PERSIST = True

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

配置重爬

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

1
SCHEDULER_FLUSH_ON_START = True

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

Pipeline 配置

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

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

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

5. 配置存储目标

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

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

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

6. 运行

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

1
scrapy crawl weibocn

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

7. 结果

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

8. 本节代码

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

9. 结语

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

Python

14.2 Scrapy-Redis 源码解析

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

1. 获取源码

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

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

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

2. 爬取队列

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

if data:
return self._decode_request(data)

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

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

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

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

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

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

3. 去重过滤

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

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

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

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

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

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

Returns
-------
str

"""
return request_fingerprint(request)

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

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

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

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

4. 调度器

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

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

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

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

5. 总结

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

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

6. 结语

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

Python

14.1 分布式爬虫原理

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

1. 分布式爬虫架构

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

2. 维护爬取队列

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

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

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

3. 怎样来去重

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

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

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

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

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

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

4. 防止中断

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

1
scrapy crawl spider -s JOBDIR=crawls/spider

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

5. 架构实现

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

Python

13.13 Scrapy 爬取新浪微博

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

1. 本节目标

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

2. 准备工作

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

3. 爬取思路

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

4. 爬取分析

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

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

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

5. 新建项目

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

1
scrapy startproject weibo

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

1
scrapy genspider weibocn m.weibo.cn

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

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

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

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

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

6. 创建 Item

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

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

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

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

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

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

7. 提取数据

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

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

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

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

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

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

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

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

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

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

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

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

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

8. 数据清洗

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

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

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

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

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

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

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

9. 数据存储

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

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

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

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

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

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

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

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

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

10. Cookies 池对接

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

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

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

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

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

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

11. 代理池对接

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

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

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

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

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

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

12. 启用 Middleware

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

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

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

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

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

13. 运行

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

1
scrapy crawl weibocn

类似的输出结果如下:

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

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

14. 本节代码

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

15. 结语

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

Python

13.12 Scrapy 对接 Docker

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

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

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

1. 本节目标

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

2. 准备工作

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

3. 创建 Dockerfile

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

1
2
scrapy
pymongo

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

1
2
scrapy>=1.4.0
pymongo>=3.4.0

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

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

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

4. 修改 MongoDB 连接

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

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

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

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

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

5. 构建镜像

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

1
docker build -t quotes:latest .

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

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

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

1
docker images

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

1
quotes  latest  41c8499ce210    2 minutes ago   769 MB

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

6. 运行

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

1
docker run quotes

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

7. 推送至 Docker Hub

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

1
docker tag quotes:latest germey/quotes:latest

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

1
docker push germey/quotes

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

1
docker run germey/quotes

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

8. 结语

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

Python

13.11 Scrapyrt 的使用

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

1. 本节目标

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

2. 准备工作

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

3. 启动服务

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

4. GET 请求

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

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

例如我们执行如下命令:

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

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

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

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

5. POST 请求

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

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

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

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

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

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

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

6. 结语

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

技术杂谈

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

Dockerfile

首先看下 Dockerfile:

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

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

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

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

反向代理

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

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

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

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

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

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

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

问题

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

1
ERR_INCOMPLETE_CHUNKED_ENCODING

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

1
Transfer-Encoding: chunked

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

什么是 Keep-Alive?

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

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

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

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

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

Content-Length

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

Transfer-Encoding

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

回归问题

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

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

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

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

这里就增加了一行:

1
proxy_http_version 1.1;

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

复盘记录

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

参考文章

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

技术杂谈

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


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


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

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

②:阿里云9折优惠码

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


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


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

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

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

新用户1折优惠售卖:

实例规格

配置

带宽

时长

价格

官网购买

ECS突发性能型t5

1核2G40G高效云盘

1M

1年

89.00元

立即抢购

ECS突发性能型t5

1核2G40G高效云盘

1M

3年

229.00元

ECS共享型n4

2核4G40G高效云盘

3M

2年

469.00元

ECS突发性能t5

2核4G40G高效云盘

5M

3年

899.00元

ECS突发性能t5

2核4G40G高效云盘

3M

3年

639.00元

ECS共享型n4

2核4G40G高效云盘

3M

3年

799.00元

ECS共享通用型mn4

2核8G40G高效云盘

5M

3年

1399.00元

ECS突发性能t5(香港)

1核1G40G高效云盘

1M

1年

119.00元

ECS网络增强型sn1ne

4核8G40G高效云盘

5M

3年

5621.00元

8核16G40G高效云盘

8M

3年

12209.00元


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

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

阿里云双12云服务器爆款

老用户五折优惠甩卖:

实例规格

CPU/内存/云盘

带宽

时长

价格

老用户优惠购买

云服务器计算型ic5

8核8G40G高效云盘

1M

1年

4433.94元

立即抢购

计算网络增强型sn1ne

8核16G40G高效云盘

1M

1年

3751.20元

通用网络增强型sn2ne

8核32G40G高效云盘

1M

1年

5353.20元

内存网络增强型se1ne

8核64G40G高效云盘

1M

1年

6793.20元

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

Python

13.10 Scrapy 通用爬虫

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

1. CrawlSpider

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

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

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

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

下面对其参数依次说明:

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

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

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

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

2. Item Loader

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

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

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

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

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

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

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

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

Identity

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

TakeFirst

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

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

输出结果如下所示:

1
1

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

Join

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

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

输出结果如下所示:

1
one two three

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

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

运行结果如下所示:

1
one,two,three

Compose

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

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

运行结果如下所示:

1
HELLO WORLD

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

MapCompose

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

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

运行结果如下所示:

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

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

SelectJmes

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

1
pip3 install jmespath

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

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

运行结果:

1
bar

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

3. 本节目标

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

4. 新建项目

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

1
scrapy startproject scrapyuniversal

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

1
scrapy genspider -l

运行结果如下所示:

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

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

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

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

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

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

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

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

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

5. 定义 Rule

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

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

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

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

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

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

所以现在 rules 就变成了:

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

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

1
scrapy crawl china

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

6. 解析页面

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

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

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

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

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

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

1
scrapy crawl china

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

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

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

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

class NewsLoader(ItemLoader):
default_output_processor = TakeFirst()

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

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

7. 通用配置抽取

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

1
scrapy genspider -t crawl universal universal

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

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

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

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

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

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

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

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

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

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

if __name__ == '__main__':
run()

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

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

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

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

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

1
python3 run.py china

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

7. 本节代码

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

8. 结语

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