破解猫眼字体反爬


写在前面

本项目很久之前就想尝试,觉得难,所以一直在拖,这次终于有时间搞了一下。猫眼的反爬虫策略还是比较强大的,会判断用户行为,如果在一段时间内频繁访问会使ip被暂时屏蔽,这个问题可以使用IP代理来解决。此外还使用了字体反爬来保护票房、票价等重要信息,而且这个字体反爬在不断更新,最初的时候使用单一的字体文件,后来升级到每次刷新网页都会更新字体文件,而且字体文件中的坐标并不一致,这让爬取变的更加困难,但是相同数字之间总有相似的地方,通过聚类思想我们仍然可以找到映射关系,这种方法不保证不会出错,因为真实的映射关系只有猫眼的程序猿那里有,我们只是使用算法进行推断,如果出现错误,可以尝试使用更精确的算法。本文使用的是欧式距离,相对简单一些。主要分为以下几个部分:

1 下载一份基础字体文件(在电影页面中审查元素找到network-font然后刷新)
2 使用fontcreator工具(百度即可)确定基础字体文件中字形与文本的映射关系
3 计算新字体文件与基础字体文件中坐标点的欧式距离(统计学中的聚类思想)
4 根据最小的欧式距离建立新字体文件中的字形与文本的映射关系

用到的包

#fonttools解析字体文件并生成xml
from fontTools.ttLib import TTFont
#使用随机的代理IP
import random
#re正则表达式,用于匹配woff文件下载地址及字体坐标等
import re
#用于获取网页信息
import requests
#用于解析网页,在本文中部分内容无法解析
from bs4 import BeautifulSoup as bs
#用于计算欧式距离
import numpy as np

建立基础映射

本部分内容暂请参考CSDN,如有时间我会整理一下。

全局变量

#伪装浏览器请求头
headers = {
            'User-Agent':'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36'
        }
#设置IP代理
proxies=[{'http':'114.103.168.183:3000'},{'http':'175.43.156.42:9999'},
         {'http':'218.64.148.47:9000'},{'http':'221.224.136.211:35101'},
         {'http:':'175.42.129.65:9999'},{'http':'117.64.225.154:1133'}]
#我所使用的基础映射
relation_table = {'uniEF67':'6','uniF0D5':'3','uniED8E':'9',
                  'uniEBBA':'4','uniF3D8':'1','uniE6A9':'8',
                  'uniE01D':'0','uniECE9':'7','uniE106':'2',
                  'uniF8D2':'5'}

请求网页

def Get_Html(url):
    #请求网页,并使用随机的代理IP
    req = requests.get(url,headers = headers,proxies = random.choice(proxies))
    #将编码设置为utf-8,不然网页中的中文都会变成乱码
    req.encoding = 'utf-8'
    #有时候requests会请求到验证中心,所以储存一下真实访问的url
    real_url = req.url
    #网页文本
    html = req.text
    #爬取到一次网页后可以暂存为文本文件,防止在debug过程中访问时出现验证中心
    """
    f = open('movie.txt','w',encoding = 'utf-8')
    f.write(html)
    f.close()
    """
    #访问到正常网页时才能获取到电影标题,所以在这里捕捉一下异常
    try:
        #从网页中获取电影标题,使用BeautifulSoup进行解析
        title = bs(html,'lxml').find('h1',class_ = "name").text
        #利用正则表达式匹配到口碑、评分人数、票房对应的加密信息
        pattern_span = re.compile('>(&#.*?\..*;)')
        ppl = re.findall(pattern_span, html)
        #返回四个主要信息
        return title,html,ppl,real_url
    except:
        #如果出现验证中心,就只返回验证中心地址
        return 0,0,0,real_url

获取新字体

def Get_Woff(html):
    #使用正则表达式匹配到woff文件的下载地址
    pattern_woff = re.compile("url\(\'(.*.woff)\'\)\s")
    #构造完整地址
    woff_url = 'http:' + re.findall(pattern_woff,html)[0]
    #将字体文件保存到本地
    new_woff = requests.get(url = woff_url,headers = headers)
    with open('new_woff.woff','wb') as f:
        f.write(new_woff.content)
        f.close()

计算欧式距离

详请参阅欧式距离

#向此函数传递两个列表,然后调用numpy计算欧式距离
def compare_axis(axis1,axis2):
    #如果向量维度不一致,则对较短的那个补充0元素
    if len(axis1) < len(axis2):
        axis1.extend([0,0] for _ in range(len(axis2) - len(axis1)))
    elif len(axis2) < len(axis1):
        axis2.extend([0,0] for _ in range(len(axis1) - len(axis2)))
    #将列表转换为数组
    axis1 = np.array(axis1)
    axis2 = np.array(axis2)
    #返回欧式距离
    return np.sqrt(np.sum(np.square(axis1 - axis2)))

从XML读取字体坐标

#file为传递的xml文件名
def Read_Font(file):
    #以读方式打开
    f_base = open(file,'r')
    #使用BeautifulSoup进行解析
    html_base = bs(f_base.read(),'lxml')
    #找到所有包含字体坐标的片段
    ttglyph = html_base.find('glyf').find_all('ttglyph')
    f_base.close()
    #返回片段列表
    return ttglyph

#清洗字符串取得坐标值列表
def Get_Axis(string):
    axis = []
    #正则匹配字符串中所有坐标,此时得到的元素类似"x="1" y="2""
    pattern = re.compile('<pt\son="[0,1]?"\s(.*?)>')
    #re只能从字符串中匹配,使用str进行转换
    axis_list = re.findall(pattern,str(string))
    #遍历匹配到的元素列表
    for item in axis_list:
        #将元素根据空格进行分割,得到[x="1",y="2"]
        zb = item.split(' ')
        #再根据“=”进行分割,替换掉双引号,并转换为整数
        x = int(zb[0].split('=')[1].replace('"',''))
        y = int(zb[1].split('=')[1].replace('"',''))
        #保存到坐标列表
        axis.append([x,y])
    #将清洗后的坐标返回
    return axis

Unicode映射

#建立基础字体文件中的uni与新字体文件中的uni之间的映射
def BaseToNew(ttglyph_base,ttglyph_new):
    basetonew = []
    #因为有一个无用的字符,所以从索引1开始遍历
    for base in ttglyph_base[1:]:
        #找到一个字体坐标与新字体的最小欧式距离后要将value重置
        value = []
        #还有另外一个无用的字符x,写一个if进行判断
        if base.get('name') != 'x':
            #调用前述方法获取坐标列表
            axis_base = Get_Axis(base)
        #枚举新字体文件中的字体坐标字符串,也可以不使用枚举,本来是想记录最小欧式距离索引的
        for index,new in enumerate(ttglyph_new[1:]):
            #同样的,忽略x
            if new.get('name') != 'x':
                #调用前述方法获取坐标列表
                axis_new = Get_Axis(new)
                #计算两个axis之间的欧式距离,并保存到value列表
                value.append(compare_axis(axis_base,axis_new))
        #新字体文件每被遍历一遍,都要计算最小的欧式距离,并记录新旧对应的Unicode编码
        for i in range(len(value)):
            #判断是否是最小的欧式距离
            if value[i] == min(value):
                #如果是,则从字符串中获取新字形的Unicode
                #因为忽略了开头的无用信息,所以这里需要加1
                new_code = ttglyph_new[i + 1].get('name')
                #获取旧字形对应的Unicode
                base_code = base.get('name')
                #建立一个映射
                basetonew.append({"basecode":base_code,"newcode":new_code})
                #如果已经找到最小的欧式距离,则无需比较后续数据
                break
    #返回建立完成的Unicode映射
    return basetonew

UNI-NUM映射

#根据基础映射和Unicode映射建立新的UNI-NUM映射
def NewToNum(basetonew):
    newtonum = {}
    #遍历基础映射中所有的键
    for key in relation_table.keys():
        #获取值,即基础映射中对应的文本
        value = relation_table[key]
        #遍历Unicode映射
        for i in range(len(basetonew)):
            #如果键与Unicode映射中的基础UNI相对应,那么就建立一个新的字形映射
            if key == basetonew[i]['basecode']:
                #在这里将新的UNI进行转换,改成全小写,并将"uni"替换为"&#x"
                #在末尾添加";"号,此时得到的Unicode编码与网页中显示的一致
                newtonum[basetonew[i]['newcode'].lower().replace('uni','&#x') + ';'] = value
    #返回新的UNI-NUM映射
    return newtonum

#根据新的映射获取结果,ppl为之前正则匹配到的加密信息
def Result(newtonum,ppl):
    result = []
    #遍历ppl列表
    for item in ppl:
        #遍历新映射中的键
        for key in newtonum.keys():
            #如果在item中能找到键,就将键替换为对应的值
            if key in item:
                item = item.replace(key,newtonum[key])
        #向result列表中添加替换完成的item,此时的item已经解密完毕
        result.append(item)
    #返回result
    return result

基础函数

def Get_Basic(ppl):
    #使用fonttools包读取字体文件
    base_font = TTFont('base_font.woff')
    base_font.saveXML('base_font.xml')

    new_font = TTFont('new_woff.woff')
    new_font.saveXML('new_woff.xml')

    #从xml文件中读取字体坐标信息
    ttglyph_base = Read_Font('base_font.xml')
    ttglyph_new = Read_Font('new_woff.xml')

    #获取新旧映射关系
    basetonew = BaseToNew(ttglyph_base, ttglyph_new)

    #获取新的UNI-NUM映射
    newtonum = NewToNum(basetonew)
    #解析加密信息
    result = Result(newtonum,ppl)

    return result

其余代码

#这是一个测试用函数,前提是你已经将网页保存为文本,使你debug的时候不会遇到验证中心
def test():
    f = open('movie.txt','r',encoding = 'utf-8')
    req = f.read()
    pattern_span = re.compile('>(&#.*?\..*;)')
    ppl = re.findall(pattern_span, req)
    f.close()
    title = bs(req,'lxml').find('h1',class_ = "name").text
    result = Get_Basic(ppl)
    print(title)
    print('猫眼口碑',ppl[0])
    print('{}万人评分'.format(ppl[1]))
    print('累计票房{}亿'.format(ppl[2]))
    print('猫眼口碑',result[0])
    print('{}万人评分'.format(result[1]))
    print('累计票房{}亿'.format(result[2]))

#主函数
def main():
    #你要爬取的目标url,批量爬取时写成列表并在后面加一个循环
    url = 'https://maoyan.com/films/42964'
    #获取电影标题,网页信息,加密数据,访问的真实url
    title,html,ppl,real_url = Get_Html(url)
    #捕捉可能出现的验证中心异常
    try:
        #从网页中的woff_url下载新的字体文件
        Get_Woff(html)
        #获取解密信息
        result = Get_Basic(ppl)
        #输出猫眼程序猿辛苦维护的数据。。。
        print(title)
        print('猫眼口碑',result[0])
        print('{}万人评分'.format(result[1]))
        print('累计票房{}亿'.format(result[2]))
    except:
        """
        如果出现了验证中心,那么就去浏览器访问一下输出的real_url
        拖动滑动条完成验证,然后重新执行程序即可
        """
        print(real_url)
#程序入口
if __name__ == '__main__':
    #test()
    main()

写在后面

整整一天,从早8点到晚22点破解了这个反爬,期间参阅了很多资料,最终发现使用聚类算法才能解析猫眼现有反爬策略。 之前学习的时候积累了一些语法的使用,不然今天肯定搞不完这个东西,或者说就算搞完了,也只是复制别人的代码,而不能完全读懂。 学习,重在积累。