爬取时间: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同城的自定义字体了


01

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


02

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


03

选中的蓝色部分就是 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 这个软件来看看字体对应的编码是什么:


04

观察我们在网页源代码中看到的编码:类似于 龤龒

对比字体文件对应的编码:类似于 uni9FA4nui9F92

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

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

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


05

可以看到,虽然每次数字对应的编码都不一样,但是编码总是这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> 标签内可以看到熟悉的类似于 0x94760x958f 的编码,其后四位字符恰好是网页字体的加密编码,可以看到每一个编码后面都对应了一个 glyph 开头的编码

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


06

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


07

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

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


08

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

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

  • 分析网页,找到对应的加密字体文件

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

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

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

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


【2x00】思维导图


09

【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 对应数字 0glyph00002 对应数字 1,以此类推,所以可以将其构造成为一个字典 glyph_list

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

循环查找这十个 code 在 xml 文件里对应的 name 的值,然后将 name 的值与字典文件的 key 值进行对比,如果两者值相同,则获取这个 keyvalue 值,最终得到的列表 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 解析库很容易提取到相关信息,这里要注意的是,租房信息来源分为三种:经纪人、品牌公寓和个人房源,这三个的元素节点也不一样,因此匹配的时候要注意


10

【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】数据截图


11

 评论


Copyright 2018-2019 TRHX'BLOG   |   鄂ICP备19003281号-4  |   正在载入...   |   站点地图  |   站长统计

UV
PV
WordCount125.4k