Python3 爬蟲實戰 — 58同城武漢出租房【加密字體對抗】


  • 爬取時間: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 打開調試模板,經過頁面分析,能夠觀察到,網站裏面凡是涉及到有數字的地方,都是顯示爲亂碼,這種狀況就是字體加密了,那麼是經過什麼手段實現字體加密的呢?python

CSS 中有一個 @font-face 規則,它容許爲網頁指定在線字體,也就是說能夠引入自定義字體,這個規則本意是用來消除對電腦字體的依賴,如今很多網站也利用這個規則來實現反爬mysql

右側能夠看到網站用的字體,其餘的都是常見的微軟雅黑,宋體等,可是有一個特殊的:fangchan-secret ,不難看出這應該就是58同城的自定義字體了
01git

咱們經過控制檯看到的亂碼事實上是因爲 unicode 編碼致使,查看網頁源代碼,咱們才能看到他真正的編碼信息github

02

要攻克加密字體,那麼咱們確定要分析他的字體文件了,先想辦法獲得他的加密字體文件,一樣查看源代碼,在源代碼中搜索 fangchan-secret 的字體信息web

03

選中的藍色部分就是 base64 編碼的加密字體字符串了,咱們將其解碼成二進制編碼,寫進 .woff 的字體文件,這個過程能夠經過如下代碼實現:sql

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

觀察咱們在網頁源代碼中看到的編碼:相似於 龤龒app

對比字體文件對應的編碼:相似於 uni9FA4nui9F92dom

能夠看到除了前面三個字符不同之外,後面的字符都是同樣的,只不過英文大小寫有所差別ide

如今咱們可能會想到,直接把編碼替換成對應的數字不就OK了?然而並無這麼簡單

嘗試刷新一下網頁,能夠觀察到 base64 編碼的加密字體字符串會改變,也就是說編碼和數字並非一一對應的,再次獲取幾個字體文件,經過對比就能夠看出來
05

能夠看到,雖然每次數字對應的編碼都不同,可是編碼老是這10個,是不變的,那麼編碼與數字之間確定存在某種對應關係,,咱們能夠將字體文件轉換爲 xml 文件來觀察其中的對應關係,改進原來的代碼便可實現轉換功能:

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文件

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】將加密字體編碼與真實字體進行匹配

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】替換掉網頁中全部的加密字體編碼

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】租房信息提取模塊

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數據庫的表

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數據庫

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】完整代碼

# =============================================
# --*-- 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

相關文章
相關標籤/搜索