# Python3 爬虫实战 — 58同城武汉出租房【加密字体对抗】

TRHX

2019-10-21

Python3 学习笔记 / 爬虫实战

字数统计:

3,988字

阅读时长≈

18分

> 爬取时间：2019-10-21
> 爬取难度：★★★☆☆☆
> 请求链接：https://wh.58.com/chuzu/
> 爬取目标：58同城武汉出租房的所有信息
> 涉及知识：网站加密字体的攻克、请求库 requests、解析库 Beautiful Soup、数据库 MySQL 的操作
> 完整代码：https://github.com/TRHX/Python3-Spider-Practice/tree/master/58tongcheng
> 其他爬虫实战代码合集（持续更新）：https://github.com/TRHX/Python3-Spider-Practice
> 爬虫实战专栏（持续更新）：https://itrhx.blog.csdn.net/article/category/9351278

# 【1x00】加密字体攻克思路

F12 打开调试模板，通过页面分析，可以观察到，网站里面凡是涉及到有数字的地方，都是显示为乱码，这种情况就是字体加密了，那么是通过什么手段实现字体加密的呢？

CSS 中有一个 `@font-face` 规则，它允许为网页指定在线字体，也就是说可以引入自定义字体，这个规则本意是用来消除对电脑字体的依赖，现在不少网站也利用这个规则来实现反爬

右侧可以看到网站用的字体，其他的都是常见的微软雅黑，宋体等，但是有一个特殊的：`fangchan-secret` ，不难看出这应该就是58同城的自定义字体了

<fancybox>
</fancybox>

我们通过控制台看到的乱码事实上是由于 unicode 编码导致，查看网页源代码，我们才能看到他真正的编码信息

<fancybox>
</fancybox>

要攻克加密字体，那么我们肯定要分析他的字体文件了，先想办法得到他的加密字体文件，同样查看源代码，在源代码中搜索 `fangchan-secret` 的字体信息

<fancybox>
</fancybox>

选中的蓝色部分就是 base64 编码的加密字体字符串了，我们将其解码成二进制编码，写进 `.woff` 的字体文件，这个过程可以通过以下代码实现：

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

    import requests
    import base64

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36'
    }

    url = 'https://wh.58.com/chuzu/'

    response = requests.get(url=url, headers=headers)
    # 匹配 base64 编码的加密字体字符串
    base64_string = response.text.split("base64,")[1].split("'")[0].strip()
    # 将 base64 编码的字体字符串解码成二进制编码
    bin_data = base64.decodebytes(base64_string.encode())
    # 保存为字体文件
    with open('58font.woff', 'wb') as f:
        f.write(bin_data)

得到字体文件后，我们可以通过 FontCreator 这个软件来看看字体对应的编码是什么：

<fancybox>
</fancybox>

观察我们在网页源代码中看到的编码：类似于 `&#x9fa4;`、`&#x9f92;`

对比字体文件对应的编码：类似于 `uni9FA4`、`nui9F92`

可以看到除了前面三个字符不一样以外，后面的字符都是一样的，只不过英文大小写有所差异

现在我们可能会想到，直接把编码替换成对应的数字不就OK了？然而并没有这么简单

尝试刷新一下网页，可以观察到 base64 编码的加密字体字符串会改变，也就是说编码和数字并不是一一对应的，再次获取几个字体文件，通过对比就可以看出来

<fancybox>
</fancybox>

可以看到，虽然每次数字对应的编码都不一样，但是编码总是这10个，是不变的，那么编码与数字之间肯定存在某种对应关系，，我们可以将字体文件转换为 xml 文件来观察其中的对应关系，改进原来的代码即可实现转换功能：

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21

    import requests
    import base64
    from fontTools.ttLib import TTFont

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36'
    }

    url = 'https://wh.58.com/chuzu/'

    response = requests.get(url=url, headers=headers)
    # 匹配 base64 编码的加密字体字符串
    base64_string = response.text.split("base64,")[1].split("'")[0].strip()
    # 将 base64 编码的字体字符串解码成二进制编码
    bin_data = base64.decodebytes(base64_string.encode())
    # 保存为字体文件
    with open('58font.woff', 'wb') as f:
        f.write(bin_data)
    # 获取字体文件，将其转换为xml文件
    font = TTFont('58font.woff')
    font.saveXML('58font.xml')

打开 `58font.xml` 文件并分析，在 `<cmap>` 标签内可以看到熟悉的类似于 `0x9476`、`0x958f` 的编码，其后四位字符恰好是网页字体的加密编码，可以看到每一个编码后面都对应了一个 `glyph` 开头的编码

将其与 `58font.woff` 文件对比，可以看到 code 为 `0x958f` 这个编码对应的是数字 `3`，对应的 name 编码是 `glyph00004`

<fancybox>
</fancybox>

我们再次获取一个字体文件作为对比分析

<fancybox>
</fancybox>

依然是 `0x958f` 这个编码，两次对应的 name 分别是 `glyph00004` 和 `glyph00007`，两次对应的数字分别是 `3` 和 `6`，那么结论就来了，每次发送请求，code 对应的 name 会随机发生变化，而 name 对应的数字不会发生变化，`glyph00001` 对应数字 `0`、`glyph00002` 对应数字 `1`，以此类推

那么以 `glyph` 开头的编码是如何对应相应的数字的呢？在 xml 文件里面，每个编码都有一个 `TTGlyph` 的标签，标签里面是一行一行的类似于 x,y 坐标的东西，这个其实就是用来绘制字体的，用 matplotlib 根据坐标画个图，就可以看到是一个数字

<fancybox>
</fancybox>

此时，我们就知道了编码与数字的对应关系，下一步，我们可以查找 xml 文件里，编码对应的 name 的值，也就是以 `glyph` 开头的编码，然后返回其对应的数字，再替换掉网页源代码里的编码，就能成功获取到我们需要的信息了！

总结一下攻克加密字体的大致思路：

- 分析网页，找到对应的加密字体文件

- 如果引用的加密字体是一个 base64 编码的字符串，则需要转换成二进制并保存到 woff 字体文件中

- 将字体文件转换成 xml 文件

- 用 FontCreator 软件观察字体文件，结合 xml 文件，分析其编码与真实字体的关系

- 搞清楚编码与字体的关系后，想办法将编码替换成正常字体

# 【2x00】思维导图

<fancybox>
</fancybox>

# 【3x00】加密字体处理模块

## 【3x01】获取字体文件并转换为xml文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

    def get_font(page_url, page_num):
        response = requests.get(url=page_url, headers=headers)
        # 匹配 base64 编码的加密字体字符串
        base64_string = response.text.split("base64,")[1].split("'")[0].strip()
        # print(base64_string)
        # 将 base64 编码的字体字符串解码成二进制编码
        bin_data = base64.decodebytes(base64_string.encode())
        # 保存为字体文件
        with open('58font.woff', 'wb') as f:
            f.write(bin_data)
        print('第' + str(page_num) + '次访问网页，字体文件保存成功！')
        # 获取字体文件，将其转换为xml文件
        font = TTFont('58font.woff')
        font.saveXML('58font.xml')
        print('已成功将字体文件转换为xml文件！')
        return response.text

由主函数传入要发送请求的 url，利用字符串的 `split()` 方法，匹配 base64 编码的加密字体字符串，利用 `base64` 模块的 `base64.decodebytes()` 方法，将 base64 编码的字体字符串解码成二进制编码并保存为字体文件，利用 `FontTools` 库，将字体文件转换为 xml 文件

## 【3x02】将加密字体编码与真实字体进行匹配

    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

    def find_font():
        # 以glyph开头的编码对应的数字
        glyph_list = {
            'glyph00001': '0',
            'glyph00002': '1',
            'glyph00003': '2',
            'glyph00004': '3',
            'glyph00005': '4',
            'glyph00006': '5',
            'glyph00007': '6',
            'glyph00008': '7',
            'glyph00009': '8',
            'glyph00010': '9'
        }
        # 十个加密字体编码
        unicode_list = ['0x9476', '0x958f', '0x993c', '0x9a4b', '0x9e3a', '0x9ea3', '0x9f64', '0x9f92', '0x9fa4', '0x9fa5']
        num_list = []
        # 利用xpath语法匹配xml文件内容
        font_data = etree.parse('./58font.xml')
        for unicode in unicode_list:
            # 依次循环查找xml文件里code对应的name
            result = font_data.xpath("//cmap//map[@code='{}']/@name".format(unicode))[0]
            # print(result)
            # 循环字典的key，如果code对应的name与字典的key相同，则得到key对应的value
            for key in glyph_list.keys():
                if key == result:
                    num_list.append(glyph_list[key])
        print('已成功找到编码所对应的数字！')
        # print(num_list)
        # 返回value列表
        return num_list

由前面的分析，我们知道 name 的值（即以 glyph 开头的编码）对应的数字是固定的，`glyph00001` 对应数字 `0`、`glyph00002` 对应数字 `1`，以此类推，所以可以将其构造成为一个字典 `glyph_list`

同样将十个 code（即类似于 `0x9476` 的加密字体编码）构造成一个列表

循环查找这十个 `code` 在 xml 文件里对应的 `name` 的值，然后将 `name` 的值与字典文件的 `key` 值进行对比，如果两者值相同，则获取这个 `key` 的 `value` 值，最终得到的列表 `num_list`，里面的元素就是 `unicode_list` 列表里面每个加密字体的真实值

## 【3x03】替换掉网页中所有的加密字体编码

    1
    2
    3
    4
    5

    def replace_font(num, page_response):
        # 9476 958F 993C 9A4B 9E3A 9EA3 9F64 9F92 9FA4 9FA5
        result = page_response.replace('&#x9476;', num[0]).replace('&#x958f;', num[1]).replace('&#x993c;', num[2]).replace('&#x9a4b;', num[3]).replace('&#x9e3a;', num[4]).replace('&#x9ea3;', num[5]).replace('&#x9f64;', num[6]).replace('&#x9f92;', num[7]).replace('&#x9fa4;', num[8]).replace('&#x9fa5;', num[9])
        print('已成功将所有加密字体替换！')
        return result

传入由上一步 `find_font()` 函数得到的真实字体的列表，利用 `replace()` 方法，依次将十个加密字体编码替换掉

# 【4x00】租房信息提取模块

    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

    def parse_pages(pages):
        num = 0
        soup = BeautifulSoup(pages, 'lxml')
        # 查找到包含所有租房的li标签
        all_house = soup.find_all('li', class_='house-cell')
        for house in all_house:
            # 标题
            title = house.find('a', class_='strongbox').text.strip()
            # print(title)

            # 价格
            price = house.find('div', class_='money').text.strip()
            # print(price)

            # 户型和面积
            layout = house.find('p', class_='room').text.replace(' ', '')
            # print(layout)

            # 楼盘和地址
            address = house.find('p', class_='infor').text.replace(' ', '').replace('\n', '')
            # print(address)

            # 如果存在经纪人
            if house.find('div', class_='jjr'):
                agent = house.find('div', class_='jjr').text.replace(' ', '').replace('\n', '')
            # 如果存在品牌公寓
            elif house.find('p', class_='gongyu'):
                agent = house.find('p', class_='gongyu').text.replace(' ', '').replace('\n', '')
            # 如果存在个人房源
            else:
                agent = house.find('p', class_='geren').text.replace(' ', '').replace('\n', '')
            # print(agent)

            data = [title, price, layout, address, agent]
            save_to_mysql(data)
            num += 1
            print('第' + str(num) + '条数据爬取完毕，暂停3秒！')
            time.sleep(3)

利用 BeautifulSoup 解析库很容易提取到相关信息，这里要注意的是，租房信息来源分为三种：经纪人、品牌公寓和个人房源，这三个的元素节点也不一样，因此匹配的时候要注意

<fancybox>
</fancybox>

# 【5x00】MySQL数据储存模块

## 【5x01】创建MySQL数据库的表

    1
    2
    3
    4
    5
    6

    def create_mysql_table():
        db = pymysql.connect(host='localhost', user='root', password='000000', port=3306, db='58tc_spiders')
        cursor = db.cursor()
        sql = 'CREATE TABLE IF NOT EXISTS 58tc_data (title VARCHAR(255) NOT NULL, price VARCHAR(255) NOT NULL, layout VARCHAR(255) NOT NULL, address VARCHAR(255) NOT NULL, agent VARCHAR(255) NOT NULL)'
        cursor.execute(sql)
        db.close()

首先指定数据库为 58tc_spiders，需要事先使用 MySQL 语句创建，也可以通过 MySQL Workbench 手动创建

然后使用 SQL 语句创建 一个表：58tc_data，表中包含 title、price、layout、address、agent 五个字段，类型都为 varchar

此创建表的操作也可以事先手动创建，手动创建后就不需要此函数了

## 【5x02】将数据储存到MySQL数据库

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10

    def save_to_mysql(data):
        db = pymysql.connect(host='localhost', user='root', password='000000', port=3306, db='58tc_spiders')
        cursor = db.cursor()
        sql = 'INSERT INTO 58tc_data(title, price, layout, address, agent) values(%s, %s, %s, %s, %s)'
        try:
            cursor.execute(sql, (data[0], data[1], data[2], data[3], data[4]))
            db.commit()
        except:
            db.rollback()
        db.close()

`commit()` 方法的作用是实现数据插入，是真正将语句提交到数据库执行的方法，使用 `try except` 语句实现异常处理，如果执行失败，则调用 `rollback()` 方法执行数据回滚，保证原数据不被破坏

# 【6x00】完整代码

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

    # =============================================
    # --*-- coding: utf-8 --*--
    # @Time    : 2019-10-21
    # @Author  : TRHX
    # @Blog    : www.itrhx.com
    # @CSDN    : https://blog.csdn.net/qq_36759224
    # @FileName: 58tongcheng.py
    # @Software: PyCharm
    # =============================================

    import requests
    import time
    import random
    import base64
    import pymysql
    from lxml import etree
    from bs4 import BeautifulSoup
    from fontTools.ttLib import TTFont

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36'
    }

    # 获取字体文件并转换为xml文件
    def get_font(page_url, page_num):
        response = requests.get(url=page_url, headers=headers)
        # 匹配 base64 编码的加密字体字符串
        base64_string = response.text.split("base64,")[1].split("'")[0].strip()
        # print(base64_string)
        # 将 base64 编码的字体字符串解码成二进制编码
        bin_data = base64.decodebytes(base64_string.encode())
        # 保存为字体文件
        with open('58font.woff', 'wb') as f:
            f.write(bin_data)
        print('第' + str(page_num) + '次访问网页，字体文件保存成功！')
        # 获取字体文件，将其转换为xml文件
        font = TTFont('58font.woff')
        font.saveXML('58font.xml')
        print('已成功将字体文件转换为xml文件！')
        return response.text

    # 将加密字体编码与真实字体进行匹配
    def find_font():
        # 以glyph开头的编码对应的数字
        glyph_list = {
            'glyph00001': '0',
            'glyph00002': '1',
            'glyph00003': '2',
            'glyph00004': '3',
            'glyph00005': '4',
            'glyph00006': '5',
            'glyph00007': '6',
            'glyph00008': '7',
            'glyph00009': '8',
            'glyph00010': '9'
        }
        # 十个加密字体编码
        unicode_list = ['0x9476', '0x958f', '0x993c', '0x9a4b', '0x9e3a', '0x9ea3', '0x9f64', '0x9f92', '0x9fa4', '0x9fa5']
        num_list = []
        # 利用xpath语法匹配xml文件内容
        font_data = etree.parse('./58font.xml')
        for unicode in unicode_list:
            # 依次循环查找xml文件里code对应的name
            result = font_data.xpath("//cmap//map[@code='{}']/@name".format(unicode))[0]
            # print(result)
            # 循环字典的key，如果code对应的name与字典的key相同，则得到key对应的value
            for key in glyph_list.keys():
                if key == result:
                    num_list.append(glyph_list[key])
        print('已成功找到编码所对应的数字！')
        # print(num_list)
        # 返回value列表
        return num_list

    # 替换掉网页中所有的加密字体编码
    def replace_font(num, page_response):
        # 9476 958F 993C 9A4B 9E3A 9EA3 9F64 9F92 9FA4 9FA5
        result = page_response.replace('&#x9476;', num[0]).replace('&#x958f;', num[1]).replace('&#x993c;', num[2]).replace('&#x9a4b;', num[3]).replace('&#x9e3a;', num[4]).replace('&#x9ea3;', num[5]).replace('&#x9f64;', num[6]).replace('&#x9f92;', num[7]).replace('&#x9fa4;', num[8]).replace('&#x9fa5;', num[9])
        print('已成功将所有加密字体替换！')
        return result

    # 提取租房信息
    def parse_pages(pages):
        num = 0
        soup = BeautifulSoup(pages, 'lxml')
        # 查找到包含所有租房的li标签
        all_house = soup.find_all('li', class_='house-cell')
        for house in all_house:
            # 标题
            title = house.find('a', class_='strongbox').text.strip()
            # print(title)

            # 价格
            price = house.find('div', class_='money').text.strip()
            # print(price)

            # 户型和面积
            layout = house.find('p', class_='room').text.replace(' ', '')
            # print(layout)

            # 楼盘和地址
            address = house.find('p', class_='infor').text.replace(' ', '').replace('\n', '')
            # print(address)

            # 如果存在经纪人
            if house.find('div', class_='jjr'):
                agent = house.find('div', class_='jjr').text.replace(' ', '').replace('\n', '')
            # 如果存在品牌公寓
            elif house.find('p', class_='gongyu'):
                agent = house.find('p', class_='gongyu').text.replace(' ', '').replace('\n', '')
            # 如果存在个人房源
            else:
                agent = house.find('p', class_='geren').text.replace(' ', '').replace('\n', '')
            # print(agent)

            data = [title, price, layout, address, agent]
            save_to_mysql(data)
            num += 1
            print('第' + str(num) + '条数据爬取完毕，暂停3秒！')
            time.sleep(3)

    # 创建MySQL数据库的表：58tc_data
    def create_mysql_table():
        db = pymysql.connect(host='localhost', user='root', password='000000', port=3306, db='58tc_spiders')
        cursor = db.cursor()
        sql = 'CREATE TABLE IF NOT EXISTS 58tc_data (title VARCHAR(255) NOT NULL, price VARCHAR(255) NOT NULL, layout VARCHAR(255) NOT NULL, address VARCHAR(255) NOT NULL, agent VARCHAR(255) NOT NULL)'
        cursor.execute(sql)
        db.close()

    # 将数据储存到MySQL数据库
    def save_to_mysql(data):
        db = pymysql.connect(host='localhost', user='root', password='000000', port=3306, db='58tc_spiders')
        cursor = db.cursor()
        sql = 'INSERT INTO 58tc_data(title, price, layout, address, agent) values(%s, %s, %s, %s, %s)'
        try:
            cursor.execute(sql, (data[0], data[1], data[2], data[3], data[4]))
            db.commit()
        except:
            db.rollback()
        db.close()

    if __name__ == '__main__':
        create_mysql_table()
        print('MySQL表58tc_data创建成功！')
        for i in range(1, 71):
            url = 'https://wh.58.com/chuzu/pn' + str(i) + '/'
            response = get_font(url, i)
            num_list = find_font()
            pro_pages = replace_font(num_list, response)
            parse_pages(pro_pages)
            print('第' + str(i) + '页数据爬取完毕！')
            time.sleep(random.randint(3, 60))
        print('所有数据爬取完毕！')

# 【7x00】数据截图

<fancybox>
</fancybox>

您的喜欢是作者写作最大的动力！❤️

- PayPal
- AliPay
- WeChatPay
- QQPay

Donate

最后更新于 2019年10月21日

爬虫

58同城

###### 上一页

#### Python3 爬虫实战 — 瓜子全国二手车

###### 爬虫 瓜子二手车

###### 下一页

#### Python3 爬虫实战 — 模拟登陆12306【点触验证码对抗】

###### 爬虫 12306

#### 评论

## ๑۩ﺴ TRHX ﺴ۩๑

本文目录
站内导航

- 所有文章
- 文章归档
- RSS订阅
- 我的友链
- 留言板
- 关于博主

欢迎光临
访客地图
交流群组

QQ 交流群 Telegram 交流群

文章分类

- BLOG
  (4)
- CDN
  (1)
- Hexo
  (9)
- Java
  (1)
- Linux
  (2)
- Markdown
  (2)
- Python3 学习笔记
  (39)
- 基础学习
  (9)
- 学习经验
  (4)
- 爬虫学习
  (18)
- 爬虫实战
  (8)
- VPS
  (1)
- WEB前端
  (2)
- 图床
  (1)

热门标签
12306 58同城 Ajax BLOG Beautiful Soup CDN Coding Pages Deepin Elicpse GitHub Pages Github Github Pages HTTPS Hexo ICP备案 JDBC JS 预加载 Markdown Material X MongoDB MySQL PEP8 PicGo Pygame Python Redis SEO SQL Server 2012 SSR Selenium Ubuntu User-Agent VMware VPS XPath if语句 input()函数 instant.page jsDelivr lxml pyspider requests spfk urllib while循环 主题个性化 代理 公安备案 函数 列表 变量 哔哩哔哩 图床 图形验证码 垃圾 备份 字典 安居客 年终总结 异常 技巧 操作列表 数据类型 文件 文件储存 无界面浏览器 模块 正则表达式 滑动验证码 点触验证码 爬虫 猫眼电影 瓜子二手车 类 继承 编辑器 虎扑论坛 豆瓣电影
最近在听