爬虫攻防之前端策略简析

看到一篇文章,介绍在反爬虫过程中,前端工程师的各种脑洞,文章见这里
文章里介绍了几个大的网站,在反爬虫过程中,采取的各式各样的策略,无不体现出前端工程师的奇葩脑洞。
还挺有意思的,就简单分析了一下,针对每个方案,看看有没有解决办法,于是整理成博客,记录一下。

1. 自定义字体形式

该方案是,自定义了一种字体,网页中使用乱码字符或者其他混淆字符,通过自定义字体的渲染成正确的显示数据。

代表网站有猫眼电影和去哪儿手机端。

1.1 猫眼电影

今日票房

如上图,是猫眼首页今日票房栏的前10名统计(截图只截取了前三名),其中的票房数据,对爬虫来说是私密数据,于是,猫眼给“加密”了。

source

网页代码显示的是一堆乱码,都是方框。。。

我们通过浏览器的开发者工具查看该部分“方框”数字,发现是用了自定义的字体渲染成可视的数字的。

maoyan.3

maoyan.4

woff字体是网页开放字体格式,详细可参见 MDN

我们把这个woff格式的字体文件下载下来,看一下这个自定义的字体里有啥奥秘呢?

这里推荐一个在线的字体编辑工具:百度字体编辑器

将下载后的woff文件字体,在百度字体编辑器中打开:

baidu.font

好了,一目了然,这个字体文件里,采用随机的Unicode编码来定义了 0-9这几个数字以及一个空白符和一个小数点,而且数字定义的顺序不是固定的,Unicode编码也不是连续的

也就是说,在HTML页面源码看到的方框,它的unicode应该和字体上的值是对应的,你可以用 charCodeAt()方法进行验证一下。

如果说,woff文件是固定的,那么其实问题很简单。但是,猫眼这个woff文件并不是固定的,而是随机的。

如果,woff字体文件里定义字体的顺序和实际数字顺序一致,或者其unicode值的顺序和真实数字是一致的,也简单。但是,顺序也是随机的。。。

所以,难道就真的没办法搞了么?

当然不!!!任何能在页面上显示的,都可以搞。

找到python的一个库 fonttools,可以解析成字体为 xml 文件,然后再根据xml里的信息找找:

其实百度字体编辑器代码是开源的,它其中依赖了一个核心库 fonteditor-core ,这个库应该也能解析字体数据,但是我在实验时,老是报错解析错误,不知为何,有兴趣的小伙伴可以自行研究一下,并分享一下研究成果,谢过。

1
2
3
4
from fontTools.ttLib import TTFont

font = TTFont('/Users/coolcao/Downloads/b0a53bf9d791622d4681b8344fd118f92088.woff')
font.saveXML('/Users/coolcao/maoyan2.xml')

生成的xml文件,有两部分很重要:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<GlyphOrder>
<!-- The 'id' attribute is only for humans; it is ignored when parsed. -->
<GlyphID id="0" name="glyph00000"/>
<GlyphID id="1" name="x"/>
<GlyphID id="2" name="uniEABA"/>
<GlyphID id="3" name="uniEB51"/>
<GlyphID id="4" name="uniE06D"/>
<GlyphID id="5" name="uniF88C"/>
<GlyphID id="6" name="uniF012"/>
<GlyphID id="7" name="uniF6C7"/>
<GlyphID id="8" name="uniE373"/>
<GlyphID id="9" name="uniF48F"/>
<GlyphID id="10" name="uniE429"/>
<GlyphID id="11" name="uniF4CA"/>
</GlyphOrder>

第一部分是字体概览,定义了字体集中的name,注意,这里id和实际数字并无关系,并不是实际的数字0,1,2…等等。name是采用unicode定义的 ,和在百度字体编辑器中的正好是一致的。

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
<TTGlyph name="uniF4CA" xMin="0" yMin="-13" xMax="511" yMax="719">
<contour>
<pt x="130" y="201" on="1"/>
<pt x="145" y="126" on="0"/>
<pt x="216" y="60" on="0"/>
<pt x="270" y="60" on="1"/>
<pt x="332" y="60" on="0"/>
<pt x="417" y="146" on="0"/>
<pt x="417" y="270" on="0"/>
<pt x="378" y="309" on="1"/>
<pt x="337" y="349" on="0"/>
<pt x="277" y="349" on="1"/>
<pt x="251" y="349" on="0"/>
<pt x="215" y="339" on="1"/>
<pt x="225" y="416" on="1"/>
<pt x="239" y="415" on="1"/>
<pt x="296" y="415" on="0"/>
<pt x="385" y="474" on="0"/>
<pt x="385" y="535" on="1"/>
<pt x="385" y="583" on="0"/>
<pt x="322" y="646" on="0"/>
<pt x="268" y="646" on="1"/>
<pt x="217" y="646" on="0"/>
<pt x="149" y="584" on="0"/>
<pt x="139" y="518" on="1"/>
<pt x="51" y="533" on="1"/>
<pt x="67" y="623" on="0"/>
<pt x="124" y="670" on="1"/>
<pt x="182" y="719" on="0"/>
<pt x="266" y="719" on="1"/>
<pt x="324" y="719" on="0"/>
<pt x="374" y="693" on="1"/>
<pt x="423" y="669" on="0"/>
<pt x="476" y="581" on="0"/>
<pt x="476" y="485" on="0"/>
<pt x="426" y="410" on="0"/>
<pt x="377" y="388" on="1"/>
<pt x="440" y="373" on="0"/>
<pt x="511" y="281" on="0"/>
<pt x="511" y="211" on="1"/>
<pt x="511" y="118" on="0"/>
<pt x="374" y="-13" on="0"/>
<pt x="270" y="-13" on="1"/>
<pt x="175" y="-13" on="0"/>
<pt x="51" y="99" on="0"/>
<pt x="42" y="189" on="1"/>
</contour>
<instructions/>
</TTGlyph>

第二部分是具体每个字体的座标集合信息,这里我只摘录了其中的一个字符F4CA的信息,我们多刷新两次页面,拿两个不同的woff文件,转换成xml文件,对比会发现,虽然每次定义的unicode不同,顺序是随机的,unicode也不连续,但是,但是,但是,有一样是相同的,那就是上面第二部分字体的座标信息。为啥一样呢?因为每个数字样式是固定的,所以画出图来座标必定是一样的。

maoyan.7

好了同志们,到这里,基本就明朗了,我们可以人工先把几个数字的座标点进行标记,然后每次刷新时,拿到新的woff字体时,通过fonttool将字体转换成xml格式,根据座标点信息,判断其uncode值分别是多少。然后再将代码中的“方框”转换成真实数字即可。

1.2 去哪儿手机端网页

去哪儿手机端采用的方案和猫眼类似,都是用的自定义字体进行混淆。

但去哪儿采用的是ttf格式的字体文件。这是不同点一。

而且,去哪儿自定义的字体,采用的unicode也比较简单,看下面:

去哪儿

去哪儿直接用的真实数字的uncode进行编码,只不过顺序和真实数字不是一一对应的,也就是说,网页源码中如果是 ‘183’,实际显示的数字却是 ‘361’。

而且每次好像也是不一样的。不过没关系,只要能用 fonttool 将其转换成xml文件,拿到里面的座标数据,那么,没跑。

1.3 起点中文网

有一个小说阅读网站,叫起点中文网,也是采用了自定义字体的形式,这也是在cnode上有一个小伙伴提问的,我这次也看了一下。

不看不知道,一看吓一跳,拿到源码里的“方框字”后,看了一下其 unicode 编码,全是一个unicode编码,如下面:

1
2
3
4
5
6
7
$ node test.js
94.37
d821
d821
d821
d821
d821

其中第一行94.37是真实显示的阅读数,后面的每一行是一个方框字对应的unicode编码,当我看到结果是,崩溃了,都是一样的,什么鬼。。。 同一个字符编码,能渲染出不同的数字来???

怎么套路和猫眼和去哪儿不一样呢?

从源码中,看到,这段阅读数加密的数字,使用了css 类名为 zxJBLkdl,顺着源码,找到类 zxJBLkdl 的定义部分,有这么一段代码:

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
<p>
<em>
<style>
@font-face {
font-family: zxJBLkdl;
src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.eot?') format('eot');
src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.woff') format('woff'), url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.ttf') format('truetype');
}
.zxJBLkdl {
font-family: 'zxJBLkdl' !important;
display: initial !important;
color: inherit !important;
vertical-align: initial !important;
}
</style>
<span class="zxJBLkdl">&#100181;&#100184;&#100186;&#100181;&#100185;</span>
</em>
<cite>万字</cite><i>|</i><em><style>@font-face { font-family: zxJBLkdl; src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.eot?') format('eot'); src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.woff') format('woff'), url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.ttf') format('truetype'); } .zxJBLkdl { font-family: 'zxJBLkdl' !important; display: initial !important; color: inherit !important; vertical-align: initial !important; }</style><span class="zxJBLkdl">&#100183;&#100185;&#100186;&#100181;&#100188;</span></em>
<cite>万总点击<span>&#183;</span>会员周点击
<style>
@font-face {
font-family: zxJBLkdl;
src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.eot?') format('eot');
src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.woff') format('woff'), url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.ttf') format('truetype');
}

.zxJBLkdl {
font-family: 'zxJBLkdl' !important;
display: initial !important;
color: inherit !important;
vertical-align: initial !important;
}
</style><span class="zxJBLkdl">&#100181;&#100185;&#100187;&#100184;</span></cite><i>|</i><em><style>@font-face { font-family: zxJBLkdl; src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.eot?') format('eot'); src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.woff') format('woff'), url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.ttf') format('truetype'); } .zxJBLkdl { font-family: 'zxJBLkdl' !important; display: initial !important; color: inherit !important; vertical-align: initial !important; }</style><span class="zxJBLkdl">&#100179;&#100186;&#100181;&#100188;</span></em>
<cite>万总推荐<span>&#183;</span>
<style>
@font-face {
font-family: zxJBLkdl;
src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.eot?') format('eot');
src: url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.woff') format('woff'), url('https://qidian.gtimg.com/qd_anti_spider/zxJBLkdl.ttf') format('truetype');
}

.zxJBLkdl {
font-family: 'zxJBLkdl' !important;
display: initial !important;
color: inherit !important;
vertical-align: initial !important;
}
</style><span class="zxJBLkdl">&#100188;&#100187;</span></cite>
</p>

在这段代码里发现了猫腻,其采用的也是随机字体的形式,比如此次刷新时的字体叫zxJBLkdl.woff,这不重要。

重要的是 <span class="zxJBLkdl">&#100181;&#100184;&#100186;&#100181;&#100185;</span>这一行,这是啥,这是使用了html转义字符输出了几个不知名的方块字,然后通过字体再渲染出真实的数字显示。

这里和普通的 &lt;等不同,这里叫做实体编号,实际转义字符都要转成实体编号才能被浏览器识别,具体请参阅这里

可是为啥我拿到的unicode是一样的呢?

答案还是得从字体文件里找。使用fonttool将字体文件转换成xml,然后你就找到了下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<GlyphOrder>
<!-- The 'id' attribute is only for humans; it is ignored when parsed. -->
<GlyphID id="0" name=".notdef"/>
<GlyphID id="1" name="period"/>
<GlyphID id="2" name="zero"/>
<GlyphID id="3" name="one"/>
<GlyphID id="4" name="two"/>
<GlyphID id="5" name="three"/>
<GlyphID id="6" name="four"/>
<GlyphID id="7" name="five"/>
<GlyphID id="8" name="six"/>
<GlyphID id="9" name="seven"/>
<GlyphID id="10" name="eight"/>
<GlyphID id="11" name="nine"/>
</GlyphOrder>

太明目张胆了,直接用英文来命名数字,再继续找这几个英文数字的定义,找到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<cmap_format_12 platformID="3" platEncID="10" format="12" reserved="0" length="148" language="0" nGroups="11">
<map code="0x18751" name="eight"/><!-- ???? -->
<map code="0x18753" name="two"/><!-- ???? -->
<map code="0x18754" name="five"/><!-- ???? -->
<map code="0x18755" name="three"/><!-- ???? -->
<map code="0x18756" name="zero"/><!-- ???? -->
<map code="0x18757" name="nine"/><!-- ???? -->
<map code="0x18758" name="six"/><!-- ???? -->
<map code="0x18759" name="four"/><!-- ???? -->
<map code="0x1875a" name="period"/><!-- ???? -->
<map code="0x1875b" name="one"/><!-- ???? -->
<map code="0x1875c" name="seven"/><!-- ???? -->
</cmap_format_12>

这里应该就是每个字符和其十六进制编码之间的关系了。将上面的转义编号的数字部分转换成十六进制,正好就是这十六进制的编码,因为这转义字符是“自定义的”,因此浏览器不能识别,只显示方框,估计在拷贝的过程中发生异常,浏览器不能识别具体的字符,都是按照方框去拷贝的,所以出来的unicode都是一样的。

到这里起点中文网的过程也明朗了,其实质和猫眼也是一样的,只是过程和形式不大一样而已。

1.4 小结

采用自定义字体的网站,思路都一致。

后端搭一套字体生成接口,随机生成一个woff字体,然后返回这个字体文件,以及各个数字的unicode对应关系,前端页面进行数据填充即可。

基本采用自定义字体的方式,都可以使用上面的思路去破解,先拿到一个字体文件,然后使用fonttool转换成xml,人工拿到每个数字的座标,然后就可以写程序,当拿到新的字体文件时,通过座标信息去判断每个数字到底是多少。

还有一种方案,使用无头浏览器进行截图,然后使用OCR工具进行文字识别,但这种方案问题在于,OCR识别存在一定的错误率,因此并不完美。这里就不说了。

2. 元素定位覆盖

这种方式太有意思了,给两套数据,前面一套假的,后面一套真的,然后显示时,通过css定位,将假的数据覆盖掉,只显示真实数据。

源码
真实数据

这个代码块里面,第一个 <b> 元素中有三个<i>元素,其中0和8是假的,是被后面的两个<b>元素的9和4覆盖掉了,显示的真实数据是 479 。

这种方式很有意思,但在反爬难度上,和第一种采用自定义字体的方式,略微低一点,感觉有点骗小孩的意思。

我们可以根据第一个b元素的宽度,以及后面b元素的宽度和偏移量来计算,拿到真正的值(实际浏览器不就是这样工作的么)。

比如上面这个,第一个b元素的宽度是54px,左偏移 -54px,第二个b元素为18px,左偏移-18px,那么很明显覆盖的是第三个数字嘛,第三个b元素左偏移-54px,覆盖的是第一个数字,这样完全可以写个程序自动判断,拿到真实数字。

这个方式没有写具体代码,但代码应该不难写,有兴趣的可以试试。

3. 背景图拼凑

还有一种形式是,使用背景图片,然后给位置,截图,拼凑出真实的数字。

如imweb这篇文章里提到的美团这种方式。但是我没找到美团哪个页面现在是这样的,应该是美团现在改版了,现在都是直接显示数字。

这种方式,和上面元素定位覆盖差不多的思想,但稍微复杂点,先把背景图片拿下来,然后再解析html拿到具体的 background-position具体的值,使用能够解析图片的类库进行截取数字,拿到的数字是图片格式的,没办法,这种只能在通过一次OCR识别了,图片,真的没办法。

因为是图片,所以与其那么复杂去解析每个位置是啥数字,倒不如直接通过无头浏览器进行截图,然后通过OCR识别来的直接,因为浏览器显示的就是图片,只能进行文字识别这条路了。

这种方式在破解时复杂点,还会存在一定的错误识别率,其实还是一种不错的反爬前端方案。但有一点不好的地方在于,由于是使用的图片,所以在显示上,不如文字那么清晰,而且在浏览器缩放时,也会有一定的模糊,给用户的体验会不好,不如文字清晰。

4. 伪类元素代替

汽车之家现在使用的是伪类元素,将词组拆开,使用伪类元素代替。

这种方式在搞起来,比上面字体要难感觉。

拿汽车之家举例,伪类的类名是随机的,而定义伪类的css样式,是js动态生成的,搞起来比较麻烦。

没有爬汽车之家的需求,不搞了,我在网上找到一篇关于搞汽车之家这种方式的文章,有兴趣的同学可以看下: 反爬虫破解系列-汽车之家利用css样式替换文字破解方法

从最终的结果来看,是js动态获取要替换的问题,然后动态替换了问题。而且现在汽车之家又升级了,要替换的文字也是动态获取的,没有任何标志,所以在实际操作起来,难度还是蛮大的。

汽车之家的前端,你可以的,佩服。。。

有兴趣的同学真的可以搞一下,搞定这个真的很有成就感。

5. 添加干扰字符并隐藏

这类有微信公共号的文章以及全网代理ip这个网站。

微信公众号
微信公众号里面,左侧下划线的部分文字为干扰文字,使用css的透明度(opacity)将透明度设置为0隐藏显示。

全网ip代理
全网代理ip这个网站,左侧画细框的部分为干扰文字,使用css的display:none隐藏不显示。

这种方案的话,需要解析每个dom元素,并根据其css样式进行选择正确的字符进行拼装。难度应该不大,没具体实施。

6. 总结

这个周末主要的精力放到了搞自定义字体部分了,觉得这个特有意思,因为之前也遇到过,当时不知道咋弄。

爬虫与反爬向来都是,道高一尺,魔高一丈。在反爬方面,除了在后端上设置反爬策略,如限制ip访问频率,限制登录用户访问频率等等,前端在反爬上,也绞尽脑汁做了不少动作。

还是那句话,反爬做的就是,不断提升爬虫解析出正确数据的成本,但没办法真正防止爬虫。

对于爬虫来说,任何你能从浏览器上看到的数据,爬虫都能拿到,只是在拿数据时,难以程度有所不同而已。

希望该文章能给大家带来一些思路,帮助大家在爬虫与反爬虫过程中,作出更多有创新性的工作。