字体反爬
本文转自https://blog.csdn.net/weixin_42555985/article/details/103641350
https://blog.csdn.net/weixin_43116910/article/details/103439930
背景分析
“字体反爬”我相信大多数从事爬虫工作的工程师都接触过,这其实不是一种常规的反爬手段,它其实是页面和前端字体文件想配合完成的一个反爬策略。像最早使用字体反爬的58同城、汽车之家到现在很多App的web页面也开始使用,例如美团、猫眼、快手抖音等等。随着爬虫工程师和反爬工程师的不断对抗,字体反爬从一开始的单纯依靠一个写死的字体文件升级成现在最新动态的字体文件,而字体反爬的攻克也有一个开始的解析字体文件做数据映射到现在依靠KNN来做动态映射,算是经历了一个又一个光辉的“升级阶段”。
在爬网站时候,有时会遇到类似以下代码
<span class="stonefont">.</span>
然而网页上显示的是数字’9.5’,这其实是一种反扒机制–字体反爬。
首先我们先理解字体反爬的原理,就是前端工程师通过自定义的字体来替换页面中某些关键的数据,那在HTML中如何使用自定义字体呢?答案就是使用@font-face,我们举个例子看看@font-face
@font-face {
font-family: <identifier>;
src: <fontsrc> [, <fontsrc>]*; <font>;
}
里面的font-family也就是一个特定的名字,src就表示你需要引用的具体的文件,而这个文件就是字体文件,一般是ttf类型,eot类型,当然,现在因为ttf文件过大,在移动端使用的时候会导致加载速度过慢,woff类型的文件最近也广泛会用,所以一般大家现在碰到的都是woff类型的文件。
要了解字体反爬机制,就必须好好了解一下字体文件。这里以woff为例。
打开woff文件,网上有推荐用百度字体编辑器,不过我打开会报错,所以还是推荐使用fontcreator。
上图是打开11.woff文件后显示内容。
一共就2行,下面一行忽略前2个格子,后面10个格子就是数字字体,上面一行是每个字体对应的编码。这个编码和本文开头的html源码中的编码是可以对应起来的。所以说woff实际上就是编码和字符的映射表 。
<span class="stonefont">.</span>
如果把&#x去掉后,后面4位和11.woff中编码的后4位一致,就能找到对应的数字。
-->$f6c7-->9
-->$e807-->5
所以,.-->9.5
现在我们懂得了原理,下面开始回顾下字体反爬的演变历程
阶段一:通过固定的字体文件进行数据替换
反爬方:一开始的时候,字体反爬还没有发展的很成熟,所以大部分网站使用字体反爬的方式是使用固定的字体文件来做数据替换,固定的字体文件就表明每个数据的编码是写死的,不变的,那么每次网站引用这个woff文件之后,都可以用相同的编码来替换想要替换的数据,这就是最初的时候的字体反爬。
应对方:既然他们的字体文件不变,那我们就直接解析他们的固定的woff文件就行,我们使用Python的fontTool库的ttLib包,代码如下:
from pathlib import Path
from fontTools.ttLib import TTFont
woff_path = Path(file).absolute().parent/"base64 (1).woff"
font = TTFont(woff_path)
font_names = font.getGlyphOrder()
font_str = [
"8", "验", "杨", "女", "3", "届", "7", "男", "高", "赵", "6", "2", "下", "以", "技", "黄", "周",
"4", "经", "专", "硕", "刘", "吴", "陈", "士", "E", "5", "中", "博", "1", "科", "大", "9", "本",
"王", "B", "无", "李", "应", "生", "校", "A", "0", "张","M"
]
print(dict(zip(font_names[2:],font_str)))
我们解析woff文件得到一定顺序的编码集再结合在FontCreator中的字符集得到字符编码字典,在我们解析HTML源码的时候替换就行了。
{'uniE032': '8', 'uniE200': '验', 'uniE267': '杨', 'uniE2DF': '女', 'uniE34E': '3', 'uniE39C': '届',
'uniE42A': '7', 'uniE481': '男', 'uniE51F': '高', 'uniE555': '赵', 'uniE595': '6', 'uniE608': '2', 'uniE6CD': '下', 'uniE72D': '以', 'uniE7C1': '技', 'uniE7C6': '黄', 'uniE7D3': '周', 'uniE841': '4', 'uniE84B': '经', 'uniE8A4': '专', 'uniE8E6': '硕', 'uniE8F4': '刘', 'uniE906': '吴', 'uniE9CF': '陈', 'uniEA8F': '士', 'uniEB2C': 'E', 'uniEBBA': '5', 'uniEBE2': '中', 'uniED0E': '博','uniEF3E': '1', 'uniF003': '科', 'uniF012': '大', 'uniF01A': '9', 'uniF02F': '本', 'uniF0D7': '王', 'uniF160': 'B', 'uniF180': '无', 'uniF205': '李', 'uniF2A0': '应', 'uniF3B5': '生', 'uniF501': '校','uniF6E9': 'A', 'uniF71C': '0', 'uniF76F': '张', 'uniF877': 'M'}
阶段二:字体信息不换,动态更换字符编码
反爬方:既然写死的woff文件太容易让人解析,那就每次都更换新的woff文件,woff文件不更换字体信息,只更换字符编码,这样,每次的字符编码都不一样,解析的时候就不能使用同一套字符编码字典去解析了。
应对方:每次同一字符的编码都不一样的情况是什么样呢?可以看看下面两个图所示
我们连续两次请求的同一个字符却有不同的编码,换个思路,同一个的字符它们的字体的关键点的坐标是不变的,就像我们在FontCreator点开某个字符看的的一样
为了得到每个字的坐标点参数,我们需要把woff文件转换成xml文件
from pathlib import Path
from fontTools.ttLib import TTFont
font1_path = Path(file).absolute().parent/"font_1.xml"
font2_path = Path(file).absolute().parent/"font_2.xml"
woff1_path = Path(file).absolute().parent/"base64 (1).woff"
woff2_path = Path(file).absolute().parent/"base64 (2).woff"
font_1 = TTFont(woff1_path)
font_2 = TTFont(woff2_path)
font_1.saveXML(font1_path)
font_2.saveXML(font2_path)
得到文件是这样的
我们根据刚才生字的两个不同编码寻找,得到下面这两个结构
我们可以看到,虽然这两个字符的坐标不一样,但是从旧字符根据一定的偏移量可以得到新字符,所以我们破解这一代字体反爬的手段可以是把最先的字符和字符的坐标保留下来,之后请求得到的字符和字符坐标,根据一定量的偏移去匹配是否是同一个字,类似这样
注:
font = TTFont('./fonts/11.woff')
print font['glyf']['uniF6C7'].__dict__
#结果为
{'numberOfContours': 2, 'yMax': 711, 'coordinates': GlyphCoordinates([(142, 151),(164, 89),(215, 35),(264, 35),(307, 32),(337, 54),(368, 73),(376, 105),(408, 137),(422, 191),(428, 217),(435, 273),(435, 300),(435, 308),(435, 313),(434, 319),(409, 287),(348, 249),(315, 223),(259, 223),(168, 223),(91, 288),(43, 354),(43, 462),(43, 574),(173, 710),(269, 710),(343, 710),(463, 633),(494, 562),(524, 503),(524, 356),(524, 214),(494, 131),(464, 54),(403, 0),(343, -39),(262, -39),(175, -39),(122, -5),(67, 57),(56, 144),(415, 466),(425, 544),(383, 576),(352, 621),(284, 635),(223, 635),(135, 539),(135, 450),(135, 387),(177, 345),(220, 301),(344, 301),(371, 345),(425, 391),(425, 452)]), 'endPtsOfContours': [41, 56], 'program': <fontTools.ttLib.tables.ttProgram.Program object at 0x000000000ECF7828>, 'flags': array('B', [1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1]), 'xMax': 525, 'xMin': 0, 'yMin': -39}
# yMax,xMax,xMin,yMin我猜想是字体外框的上下左右4个点,它们限定了字体所在范围。
# ‘numberOfContours’: 2,应该就是2个轮廓。
# coordinates的值就是构成“9”字轮廓的坐标点,一共57个。
# flags 返回一个字符对应的on字段的0和1组成的列表
# 其实上述的图主要有两个信息,一个是x,y坐标信息,还有一个是对应点0、1值。下载多几个字体文件就可以发现,同一个字符在不同的字体文件中x,y坐标是变化的,0,1值不变,还有坐标的数量也不变,所以可以利用0,1的值来判断是否为同一个字
from pathlib import Path
from fontTools.ttLib import TTFont
# woff文件所在的路径
woff1_path = Path(__file__).absolute().parent/"base64 (1).woff"
woff2_path = Path(__file__).absolute().parent/"base64 (2).woff"
font_1 = TTFont(woff1_path)
font_2 = TTFont(woff2_path)
font_old_order = font_1.getGlyphOrder()[2:]
# 得到的结果为一个列表,类似于:
# ['uniF6C7', 'uniF7C5', 'uniF53B', 'uniE765', 'uniF2E2', 'uniE764', 'uniF638', 'uniE807', 'uniE919', 'uniEB35']
font_new_order = font_2.getGlyphOrder()[2:]
def get_font_flags(font_glyphorder, font_ttf):
f = {}
for i in font_glyphorder:
flags = font_ttf['glyf'][i]
if "flags" in flags.__dict__:
# f[(1,0,1)]=i;
f[tuple(list(flags.flags))] = i
return f
def comp(arr1, arr2):
if len(arr1) != len(arr2):
return 0
for i in range(len(arr2)):
if arr1[i] != arr2[i]:
return 0
return 1
def get_old_new_mapping():
old, new = get_font_flags(font_glyphorder=font_old_order, font_ttf=font_1), get_font_flags(
font_glyphorder=font_new_order, font_ttf=font_2)
result_dict = {}
for key1, value1 in old.items():
for key2, value2 in new.items():
if comp(key1, key2):
result_dict[value1] = value2
return result_dict
print(get_old_new_mapping())
我们会得到新旧两个字符的映射
{'uniE032': 'uniF889', 'uniE595': 'uniEB52', 'uniF01A': 'uniF07A', 'uniF71C': 'uniEBDE'}
阶段三:有了动态的编码,再搞个动态字体坐标?
反爬方:动态更换字符编码集也能根据字体坐标来破解,要是新旧两个字符的坐标不是按照一定的偏移量来做的呢?例如我们新的字符和旧的字符的字体不一样,新的字体做了一定量的变形,导致某些坐标的缺少以及坐标的偏移量不一致,所以可以做几百套不同字体坐标,不同字符编码的动态字体集(真的变态!)。
应对方:
这一阶段的反爬看到过很多大佬的实现:
- 有使用阈值来做的,不过阈值是写死的,也就是说明成功其实有点靠运气,有时候返回的两个字符坐标差值在阈值内,有时不在,所以这一个方案有点不太靠谱。
- 有使用ocr来做的,哈哈,真的是秀,利用ocr来做的原理就是先利用坐标勾勒出汉字图样,接着识别出汉字,再把相同汉字的不同编码做对应,这样也能得出结果,效果没有具体去测算,不过使用ocr来识别汉字应该相对于tf,pytorch等来说效果会差点。
- 之前看到大壮哥使用KNN来做,是个好想法,而且也不用去识别图片成汉字,资源消耗和速率上相对来说会小点,原理就是如果一个样本在特征空间中的k个最相邻的样本中的大多数属于某一个类别,则该样本也属于这个类别,并具有这个类别上样本的特性。放在字体这个例子中,就是新字体文件中哪个字符离旧字体文件中的某个字符距离较近,它就属于这个字符的类别,也就是和这个字符是一样的。
KNN思路分析
在网上找到了通过机器学习来进行识别的方法,很不错。
https://www.jianshu.com/p/bad05a3c995f
利用该方法需要有机器学习的基础,这里使用的是机器学习中最简单的knn算法,详细参见https://zhuanlan.zhihu.com/p/31747901。
训练部分如下
def get_coor_info(font, data):
#字体的编码,uniE2DC。第一个和第二个不是数字,舍弃
glyf_order = font.getGlyphOrder()[2:]
info = list()
for i, g in enumerate(glyf_order): #遍历字体编码
#获得数字对应的轮廓坐标。GlyphCoordinates对象
#GlyphCoordinates([(43, 331),(44, 468),(95, 620),(164, 667),(202, 710),
#(267, 710),(398, 710),(459, 617),(487, 574),(504, 508),(520, 458),(520, 325),
#(520, 271),(508, 166),(494, 126),(466, 46),(414, 4),(358, -39),(282, -53),
#(172, -39),(115, 37),(43, 127),(43, 350),(135, 335),(135, 154),(177, 93),
#(218, 35),(276, 35),(343, 35),(385, 91),(428, 163),(428, 335),(423, 515),
#(346, 635),(265, 648),(218, 635),(181, 583),(135, 515)])
coors = font['glyf'][g].coordinates
#print "*********************************************"
#print type(coors)
#print 'coors=',coors
#把GlyphCoordinates对象的轮廓坐标变化为list
#[43, 331, 44, 468, 95, 620, 164, 667, 202, 710, 267, 710, 398, 710, 459, 617,
#487, 574, 504, 508, 520, 458, 520, 325, 520, 271, 508, 166, 494, 126, 466, 46,
#414, 4, 358, -39, 282, -53, 172, -39, 115, 37, 43, 127, 43, 350, 135, 335, 135,
#154, 177, 93, 218, 35, 276, 35, 343, 35, 385, 91, 428, 163, 428, 335, 423, 515,
#346, 635, 265, 648, 218, 635, 181, 583, 135, 515]
coors = [_ for c in coors for _ in c]
#print type(coors)
#print 'coors new=',coors
coors.insert(0, data[i]) #列表中添加第一个元素,这个列表属于哪个数字
#10个数字坐标合并的列表。info[coors[i]]
info.append(coors)
#print type(info)
#print info
return info
def get_font_data() : #
font1 = TTFont('./fonts/1.woff')
data1 = [4, 2, 7, 6, 5, 1, 0, 9, 8, 3]
coordinates1 = get_coor_info(font1, data1)
font2 = TTFont('./fonts/2.woff')
data2 = [5, 2, 7, 9, 8, 3, 6, 4, 0, 1]
coordinates2 = get_coor_info(font2, data2)
font3 = TTFont('./fonts/3.woff')
data3 = [3, 6, 2, 7, 8, 0, 9, 5, 4, 1]
coordinates3 = get_coor_info(font3, data3)
font4 = TTFont('./fonts/4.woff')
data4 = [7, 3, 2, 4, 5, 0, 8, 1, 9, 6]
coordinates4 = get_coor_info(font4, data4)
font5 = TTFont('./fonts/5.woff')
data5 = [3, 2, 9, 7, 8, 6, 1, 5, 0, 4]
coordinates5 = get_coor_info(font5, data5)
font6 = TTFont('./fonts/6.woff')
data6 = [0, 4, 9, 1, 7, 6, 3, 8, 2, 5]
coordinates6 = get_coor_info(font6, data6)
font7 = TTFont('./fonts/7.woff')
data7 = [9, 8, 3, 4, 2, 5, 1, 6, 7, 0]
coordinates7 = get_coor_info(font7, data7)
font8 = TTFont('./fonts/8.woff')
data8 = [0, 6, 5, 7, 1, 9, 4, 3, 2, 8]
coordinates8 = get_coor_info(font8, data8)
font9 = TTFont('./fonts/9.woff')
data9 = [0, 8, 1, 7, 6, 5, 9, 4, 2, 3]
coordinates9 = get_coor_info(font9, data9)
font10 = TTFont('./fonts/10.woff')
data10 = [8, 4, 5, 9, 3, 2, 1, 6, 7, 0]
coordinates10 = get_coor_info(font10, data10)
#info[coors[i]],列表。长度100。10个字符集,每个字符集包含10个数字
#注意:列表中包含的每个列表元素(每个数字的轮廓坐标)的长度并不一致。
# [1,2,3] + [1,2,3] => [1,2,3,1,2,3]
infos = coordinates1 + coordinates2 + coordinates3 + coordinates4 + coordinates5\
+coordinates6 + coordinates7 + coordinates8 + coordinates9 + coordinates10
#print type(infos)
#print len(infos)
return infos
def get_knn():
#下面是机器学习过程
# 处理缺失值
#缺失值可以用常量值或使用缺失值所在列的统计信息(平均值、中位数或最频繁)进行填充。
#这里使用包含缺少值的列(轴0)的平均值替换缺少值。
imputer = SimpleImputer(missing_values=np.nan, strategy='mean')
#变化为DataFrame对象。[100 rows x 125 columns],补全末尾缺失列
infos=get_font_data()
data_pd = pd.DataFrame(imputer.fit_transform(pd.DataFrame(infos)))
#print data_pd
# 取出特征值\目标值
x = data_pd.drop([0], axis=1) #特征值。按行删除第一个元素,也就是删除DataFrame数据的第一列。
#print x.shape[0],x.shape[1] #100 124.行,列
y = data_pd[0] #目标值。DataFrame数据的第一列
#print y.shape[0] #100行
# 分割数据集
# 随机分割。x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=0)
x_train = x.head(90) #取前90行
y_train = y.head(90)
x_test = x.tail(10) #取后10行
#print x_test.shape[0],x_test.shape[1] #10 124。行,列
y_test = y.tail(10)
#print y_test
#90 8.0
#91 4.0
#92 5.0
#93 9.0
#94 3.0
#95 2.0
#96 1.0
#97 6.0
#98 7.0
#99 0.0
# 进行算法流程。最类似的3个紧邻数字
knn = KNeighborsClassifier(n_neighbors=3)
# 开始训练
#使用KNeighborsClassifier 对象进行fit创建出模型
knn.fit(x_train, y_train)
#print knn
#KNeighborsClassifier(algorithm='auto', leaf_size=30, metric='minkowski',
# metric_params=None, n_jobs=None, n_neighbors=3, p=2,
# weights='uniform')
# 预测结果。输入测试数据。
y_predict = knn.predict(x_test)
#print y_predict #[8. 4. 5. 9. 3. 2. 1. 6. 7. 0.]
# 得出准确率。预测结果和实际值对比。显然是1.0,也就是100%
#print knn.score(x_test, y_test)
return knn,infos,x.shape[1]
简单说明
- 先通过网站获得10个字体文件,然后标注每个文件中每个数字的位置
- 然后获得每个数字的坐标,处理为一个列表。并在列表第一个元素插入对应的数字。
- 把10个字体文件合并,得到100行的数据
- 对100行数据进行预处理,得到同样的列数(每个数字的坐标数量不一样,那列数也就不一样)。缺失列是用平均值填充了
- 然后把数据按照行拆分,取前90行数据作为训练数据,后10行作为测试数据
- 再把训练数据和测试数据按列拆分。第一列是目标值,剩余列是特征值。
- 最后返回knn分类器,数据集合和列数
预测部分如下
训练完成后,就可以进行网站文字的识别。其实也就是对爬网站获得的字体文件预测每个编码对应的数字,然后再替换html源码中的编码。
def get_predict(a,infos,font,y_dim):
#a:knn分类器
#infos:训练数据列表。因为本次数据预处理需要
#font:本次下载字体
glyf_order = font.getGlyphOrder()[2:] #['uniF6C7', 'uniF7C5', 'uniF53B', 'uniE765', 'uniF2E2', 'uniE764', 'uniF638', 'uniE807', 'uniE919', 'uniEB35']
info = list()
for i, g in enumerate(glyf_order): #遍历字体编码
coors = font['glyf'][g].coordinates
coors = [_ for c in coors for _ in c]
coors.insert(0, 0) #列表中添加第一个元素。这里填0没有用,只是为了补足一位。因为不知道对应哪个值
if len(coors)> y_dim: #保持维度一致。如果比训练数据多,那就去除多余的坐标点。
coors = coors[:y_dim - infos.shape[0]]
#10个数字坐标合并的列表。info[coors[i]]
info.append(coors)
infos1=infos+info
imputer = SimpleImputer(missing_values=np.nan, strategy='mean')
#变化为DataFrame对象。补全末尾缺失列
data_pd = pd.DataFrame(imputer.fit_transform(pd.DataFrame(infos1)))
#print data_pd.shape[0],data_pd.shape[1] #110 125
x = data_pd.drop([0], axis=1) #特征值。去除第一列
x_test = x.tail(10) #取后10行,本次要预测的数据
# 预测结果。输入测试数据。
y_predict = a.predict(x_test)
#print 'y_predict=',y_predict #<type 'numpy.ndarray'>
return y_predict
简单说明
- 和训练过程一致,把字体文件中10个数字的坐标进行处理。
- 这里需要注意,如果有数字坐标数量超过了训练时候数据的维度(也就是训练数据中某个数字最大的坐标数),那就要把多余的部分去除。否则无法进行预测,会报错。其实如果坐标点足够多,轮廓中减少一个坐标点,是不会影响预测结果的。
- 然后再和测试数据集合合并,标准化数据。
- 然后把本次的10个数字再分离出来进行预测。
- 返回测试结果