python反反爬蟲系列一(文本混淆)

python反反爬蟲系列一(文本混淆)

聲明:僅供技術交流,請勿用於非法用途,如有其它非法用途造成損失,和本博客無關

1,圖片僞裝反爬蟲

圖片僞裝:即你在瀏覽器上看到的文字或者數字,其實是一張圖片,那麼在網頁源代碼裏面是找不到你想要的文字的,這種混淆方式並不會影響用戶閱讀,但是可以讓爬蟲程序無法獲得“所見”的文字內容。這就是圖片僞裝反爬蟲。

那麼攻破的思路是:找不到文字,那麼就拿圖片唄,識別圖片裏面的文字或者數字即可。

網上很多人用的是光學字符識別技術(PyTesseract 庫)來識別圖中的文字,但光學字符識別技術也有一定的缺陷,在面對扭曲文字、生僻字和有複雜干擾信息的圖片時,它就無法發揮作用了。而且要安裝的東西還挺多(主要是不想裝)

所以我使用的是百度的文字識別API,通用文字識別日調用量就有50000次,而且我發現識別率也是很高的。

下面以廣西人才網爲例子


第一步、分析頁面



可以看到聯繫電話是一張圖片,但是正常第一次看到頁面都不覺得它是一張圖片吧。查看網頁源代碼看到了圖片的下載鏈接,那麼只要拿到這張圖片然後把它識別出來就行了。


第二步、編寫代碼

from parsel import Selector
import time
import requests
import base64
import urllib
import os

# 調用百度API獲取聯繫電話號碼
def ocr_get_phone(ak,sk,img_path):
    host = f'https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={ak}&client_secret={sk}'
    response = requests.get(host)
    access_token=response.json()['access_token'] #獲取access_token
    api_url='https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic' + '?access_token=' + access_token #通用文字識別
    headers={'Content-Type':'application/x-www-form-urlencoded'}
    f=open(img_path,'rb')
    img=base64.b64encode(f.read())
    f.close()
    data={'image':img}
    response=requests.post(api_url,data=data,headers=headers)
    result=response.json()['words_result'][0]['words']
    os.remove(img_path)
    return result
#獲取信息
def get_data(ak,sk):
    url='https://www.gxrc.com/jobDetail/aac3654c1149499b950a1d70fb13e285'
    headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36'}
    r=requests.get(url,headers=headers)
    response=Selector(r.text)
    p_list=response.xpath('//*[text()="聯繫方式"]/following-sibling::div[1]/p')
    info=['聯繫人','聯繫電話','電子郵箱','聯繫地址']
    massage=[]
    for p in p_list:
        temp=p.xpath('./label/text()').get()
        if temp is not None:
            massage.append(temp)
        else:
            img_url=urllib.parse.urljoin(url,p.xpath('.//img/@src').get())
            img_path=str(time.time())+'.jpg'
            urllib.request.urlretrieve(img_url,img_path)
            phone=ocr_get_phone(ak,sk,img_path)
            massage.append(phone)
    return dict(zip(info,massage))

if __name__ == '__main__':
    ak='XXX' #百度API創建應用即可拿到
    sk='XXX' #百度API創建應用即可拿到
    data=get_data(ak,sk)
    for key,value in data.items():
        print(key+':'+value,end='\n')

可以看到輸出如下:

聯繫人:黃小姐
聯繫電話:0771-3925354
電子郵箱:[email protected]
聯繫地址:南寧市高新區新苑路17號華成都市廣場華城大廈A座1505-1510

通過比對,發現完全正確!

2,css偏移反爬蟲

css偏移,即通過修改css樣式,打亂文字的排版使得網頁源代碼中的信息與在瀏覽器上看到的信息不一致,從而達到反爬蟲的效果。

下面以去哪兒網爲例子


第一步、分析頁面



通過分析、對比,發現了其隱藏的規律:顯示在網頁上的數字只有<i>標籤,然後其通過下面的<b>標籤來更改<i>標籤上的數字;第一個<b>標籤的style屬性已經說明了其寬度即圖中的style="width:48px;left:-48px",平均一個數字的寬度<i>標籤也已說明即圖中的style="width: 16px;",而下面的<b>標籤上的數字是通過其設定的style屬性來錯位更改<i>標籤上的數字。

聽起來好像有那麼一點繞,不過沒關係,下面通過一張圖來補充說明一下


第二步、編寫代碼

ps:爬這個還是有點難度的其實,因爲它不只這一個css偏移,還有一些其他的,比如說:

  1. 如果你直接用requests發請求獲取源代碼,返回的卻不是頁面的信息,而是有一大部分的js代碼;
  2. 然後呢,用selenium來打開網頁會發現打開之後找不到航班信息,這個其實是檢測到selenium
  3. 存在心跳機制

限於本人當前的知識能力範疇,我選擇了selenium來爬,具體破解請看代碼及註釋

from selenium import webdriver
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from parsel import Selector
import time
from pandas import DataFrame
import requests

def get_df(start_city,arrive_city):
    options=webdriver.ChromeOptions()
    options.add_experimental_option("excludeSwitches", ["enable-automation"]) #消除正在受自動化測試的警告
    options.add_experimental_option('useAutomationExtension', False) #消除正在受自動化測試的警告
    script = '''
    Object.defineProperty(navigator, 'webdriver', {
        get: () => undefined
    })
    '''
    driver=webdriver.Chrome(options=options)
    driver.maximize_window()
    # 執行script語句破解selenium的反爬蟲
    driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {"source": script}) #將windows.navigator.webdriver設置爲undefined
    url='https://flight.qunar.com/site/oneway_list.htm?'
    params={
        'searchDepartureAirport': start_city,
        'searchArrivalAirport': arrive_city,
        'searchDepartureTime': time.strftime('%Y-%m-%d',time.gmtime(time.time())), #獲取當前日期時間
        'searchArrivalTime': time.strftime('%Y-%m-%d',time.gmtime(time.time()+(86400*5))) #加多五天
    }
    r=requests.get(url,params=params)
    driver.get(r.url) #爲了拿到加了參數的url鏈接
    time.sleep(2)
    driver.refresh() #刷新是確保頁面正常
    time.sleep(2)
    names=[] # 航班名稱
    citys=[] # 城市-城市
    dep_times=[] # 起飛時間
    arr_times=[] # 到達時間
    prices=[] # 價格
    while True:
        response=Selector(driver.page_source)
        div_list=response.xpath('//div[@class="b-airfly"]')
        for div in div_list:
            name=div.xpath('.//div[@class="air"]/span/text()').get()
            dep_time=div.xpath('.//div[@class="sep-lf"]/h2/text()').get()
            arr_time=div.xpath('.//div[@class="sep-rt"]/h2/text()').get()
            rels=div.xpath('.//em[@class="rel"]')
            # 下面是處理價格的css反爬,邏輯跟上面圖片說明的差不多
            for rel in rels:
                nums=rel.xpath('.//text()').getall()
                total=int(re.findall('left:-(\d+)px',rel.xpath('./b[1]/@style').get())[0])
                average=int(re.findall('width: (\d+)px',rel.xpath('.//i[1]/@style').get())[0])
                result=nums[:total//average]
                pxs=[int(re.findall('left:-(\d+)px',i)[0])//average for i in rel.xpath('.//b/@style').getall()[1:]]
                for key,value in dict(zip(pxs,nums[total//average:])).items():
                    result[-key]=value
            price=''.join(result)
            names.append(name)
            dep_times.append(dep_time)
            arr_times.append(arr_time)
            prices.append(price)
            citys.append(f'{start_city}-{arrive_city}')
        next_page=driver.find_elements_by_xpath('//a[text()="下一頁"]')
        if next_page != []:
            ActionChains(driver).send_keys(Keys.END).perform()
            next_page[0].click()
            time.sleep(2)
        else:
            break
#     driver.quit()
    # 將爬取的數據放在Dataframe中,方便後續保存
    columns=['航空公司','地點','起飛時間','着陸時間','價格']
    df=DataFrame([names,citys,dep_times,arr_times,prices]).T
    df.columns=columns
    return df

if __name__ == '__main__':
    df=get_df(start_city='北京',arrive_city='上海')

輸出df如下:

對比如下:

可以發現,完全正確!

3,自定義字體反爬蟲

自定義字體反爬蟲,即目標站點自己定義的一中字體,通常以woffsvgttfeot格式的文件嵌套在網頁端上,通過特定的編碼與字體一一映射。用戶不需下載該自定義字體,字體就能在頁面上顯示出來,這種混淆方式也不會影響用戶閱讀,只是在網頁源代碼中出現亂碼的情況,進而達到反爬蟲的效果。

下面以大衆點評爲例子


第一步、分析頁面


不單單是商店的基本信息的字體這樣,包括菜名、用戶評論等等,都是這樣的情況。單獨複製一個字符運行看看:

其實,這就是一個特殊的字體編碼,瀏覽器根據這個編碼從自定義字體中找到與之匹配的真正的字體,然後渲染在頁面上的。

所以我們的目標是找到這個自定義字體的文件,找出字體的映射關係,然後就可以解析出網頁源代碼中的特殊字體。

這中字體通常是在一個css的文件當中,打開瀏覽器的檢查,在Network下的CSS中可以找到這個css請求,沒有的話,刷新頁面就加載出來了

可以看到num、address、shopdesc這樣的關鍵字,這不就是對應特殊字符的class屬性嗎?ok,找到自定義字體,複製鏈接在瀏覽器中打開,就能直接下載字體,直接下載後面的woff文件即可。

那麼,下載下來怎麼打開這個文件呢,這裏推薦FontLab VI,百度一下就能找到資源,30天試用期,不過好像有破解版的。

安裝之後,打開下載的字體文件,可以看到:

可以看到,這些字體上面對應着一個編碼,其實這正是網頁上的特殊字符,剛剛運行的那個\ue765就是unie765所對應的字體,即數字8

拿到字體之後,接下來就是找到與真正字體一一對應的特殊編碼了,那麼怎麼用python來操作呢,這裏用到了一個第三方庫fontTools,直接pip install fontTools即可,它可以讀取並操作woff文件。但是對這個庫不怎麼熟悉,這裏只用到了它的轉xml的函數,最後用標準庫xml來操作即可。

在此之前,需要先把文件裏面的所有字按順序記錄下來,並保存在一個列表中。可是,一看有603個字,這得敲到猴年馬月呀。所以,我這裏使用一種比較簡單的做法:也是用百度的文字識別API,這次要用高精度版點擊跳轉,親測識別率99%,準確率99%

首先你的FontLab VI要設置一下,將編碼、和多餘的邊框給去掉,不然會干擾到識別


第二步、編寫代碼

手動截圖保存,共截取6張圖片,用以下代碼去識別字體:

import requests
import base64

def ocr_get_fonts(ak,sk,img_paths):
    fonts=['',' '] # 前兩個空的字體先定義好
    host = f'https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={ak}&client_secret={sk}'
    response = requests.get(host)
    access_token=response.json()['access_token']
    api_url='https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic' + '?access_token=' + access_token
    headers={'Content-Type':'application/x-www-form-urlencoded'}
    for j,img_path in enumerate(img_paths):
        f=open(img_path,'rb')
        img=base64.b64encode(f.read())
        f.close()
        data={'image':img}
        response=requests.post(api_url,data=data,headers=headers)
        result=response.json()
        words=''
        for eachone in result['words_result']:
            words+=eachone['words']
        for i in words:
            fonts.append(i)
        print(f'第{j+1}張識別了{len(words)}個字') #爲了查看哪一張出現了識別錯誤,然後對照修正
    return fonts

if __name__ == '__main__':
	ak='XXX' #百度API創建應用即可拿到
    sk='XXX' #百度API創建應用即可拿到
	img_paths=list(map(lambda x:'./fonts_jpg/'+x,os.listdir('./fonts_jpg')))#我這裏截的圖放在了./fonts_jpg目錄下
	fonts=ocr_get_fonts(ak,sk,img_paths) #拿到所有識別的字體

運行輸出如下:

第1張識別了109個字
第2張識別了112個字
第3張識別了112個字
第4張識別了112個字
第5張識別了112個字
第6張識別了43個字

明顯看出第一張圖識別少了一個字,然後通過比對,發現缺了個“一”字,然後對得到的字體再做處理,如下:

for i,num in enumerate(fonts):
    if num == '容': # 意思是找到“容”字,然後在其前面加上個“一”字
        index1=i
fonts.insert(index1,'一')


那麼,現在拿到了所有字體,接下來就是找到對應字體的筆畫輪廓圖,因爲我發現:

  1. 字體的編碼不同woff文件是不一樣的,不能與字體相對應
  2. 字體的輪廓圖不同文件也是一樣的,因此拿這個來當鍵值就行

那麼,怎麼拿到字體的輪廓圖呢,那就用到xml了,具體處理如下:

try:
	import xml.etree.cElementTree as et #速度更快
except:
	import xml.etree.ElementTree as et

root = et.parse('num.xml')
names=root.findall('./GlyphOrder/GlyphID') #按順序拿到所有編碼
xyons=[] # 存儲輪廓數據即x、y、on的值
for name in names:
    bihua=[]
    temp=name.attrib['name']
    pts=root.findall(f'./glyf/TTGlyph[@name="{temp}"]/contour/pt')
    for pt in pts:
        bihua.append(pt.attrib)
    xyons.append(bihua)

拿到字體和字體的輪廓數據之後呢,要保存起來,方便下次直接使用,不用重新截圖識別字體。

def save_font(fonts,xyons):
    data=dict(zip(fonts,xyons)) # 注意這裏,字體作爲key,輪廓作爲value,因爲輪廓是列表不能當鍵值
    json_str = json.dumps(data, indent=4,ensure_ascii=False)
    with open('fonts.json', 'w', encoding='utf-8') as f:
        f.write(json_str)

那麼,接下來就是重頭戲爬取數據了,但是呢,可以發現的是有些字需要去比對編碼獲取真正的字體,而有些又不用。所以我的思路是:
在一個xpath語句下,拿到:

  • 此語句下的所有class屬性值
  • class屬性值對應的text值
  • 此語句下的所有的text值

然後有class值節點的text值去對應class值的文件中找到其對應的text值的輪廓數據,再比對我們保存的字體數據,找到真正的字體返回來,最後拼接所有text值,得到一句完整的和瀏覽器上看到的話。具體請看代碼及代碼註釋。

try:
    import xml.etree.cElementTree as et #速度更快
except:
    import xml.etree.ElementTree as et
import requests
from parsel import Selector
from fontTools.ttLib import TTFont
import urllib
import os
import re

class Perfect():
    '''
    這個類的主要功能是先加載字體文件,
    通過傳入進來的xpath語句進行解析,
    返回得到的真正字體的字符串列表。
    即:所有處理對應輪廓數據與字體的全部邏輯,調用其xpath函數即可
    '''
    
    def __init__(self):
        self.fonts,self.xyons=self.load_font
    
    def check_down_font(self,path='./fonts_file/'):
        if not os.path.exists(path):
            os.makedirs(path)

        url='http://www.dianping.com/shop/112223644'
        headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36'}
        r=requests.get(url,headers=headers)
        response=Selector(r.text)
        fonts_link='http:'+response.xpath('//link[contains(@href,"svgtextcss")]/@href').get()
        r=requests.get(fonts_link,headers=headers)
        now_filenames=[i + '.woff' for i in re.findall('@font-face{font-family: "PingFangSC-Regular-(\w+)"',r.text)]
        now_font_urls=['https:' + i for i in re.findall('format\("embedded-opentype"\),url\("(.*?)"\)',r.text)]
        if not os.path.exists('check.json'):
            json_str = json.dumps(dict(zip(now_filenames,now_font_urls)), indent=4,ensure_ascii=False)
            with open('check.json', 'w', encoding='utf-8') as f:
                f.write(json_str)
        with open('check.json','r',encoding='utf-8') as f:
            check_json=json.load(f)
        filenames=list(check_json.keys())
        font_urls=list(check_json.values())

        if (filenames==now_filenames) and (font_urls==now_font_urls):
            print('字體沒有被修改,不需要重新下載!')
            if not os.path.exists(path + 'num.xml'):
                num = TTFont(path + 'num.woff')
                num.saveXML(path + 'num.xml')
            if not os.path.exists(path + 'shopdesc.xml'):
                shopdesc = TTFont(path + 'shopdesc.woff')
                shopdesc.saveXML(path + 'shopdesc.xml')
            if not os.path.exists(path + 'review.xml'):
                review = TTFont(path + 'review.woff')
                review.saveXML(path + 'review.xml')
            if not os.path.exists(path + 'address.xml'):
                address = TTFont(path + 'address.woff')
                address.saveXML(path + 'address.xml')
            if not os.path.exists(path + 'dishname.xml'):
                dishname = TTFont(path + 'dishname.woff')
                dishname.saveXML(path + 'dishname.xml')
            if not os.path.exists(path + 'hours.xml'):
                hours = TTFont(path + 'hours.woff')
                hours.saveXML(path + 'hours.xml')
        else:
            print('字體已變更,正在下載最新字體!')
            json_str = json.dumps(dict(zip(now_filenames,now_font_urls)), indent=4,ensure_ascii=False)
            with open('check.json', 'w', encoding='utf-8') as f:
                f.write(json_str)
            for filename,font_url in zip(now_filenames,now_font_urls):
                urllib.request.urlretrieve(font_url,path+filename)
                time.sleep(2)
            num = TTFont(path + 'num.woff')
            num.saveXML(path + 'num.xml')
            shopdesc = TTFont(path + 'shopdesc.woff')
            shopdesc.saveXML(path + 'shopdesc.xml')
            review = TTFont(path + 'review.woff')
            review.saveXML(path + 'review.xml')
            address = TTFont(path + 'address.woff')
            address.saveXML(path + 'address.xml')
            dishname = TTFont(path + 'dishname.woff')
            dishname.saveXML(path + 'dishname.xml')
            hours = TTFont(path + 'hours.woff')
            hours.saveXML(path + 'hours.xml')
    
    @property
    def load_font(self):
        with open('fonts.json','r',encoding='utf-8') as f:
            data=json.load(f)
        xyons=list(data.values())
        fonts=list(data.keys())
        return fonts,xyons
    
    def get_word(self,i,class_name,path='./fonts_file/'):
        uni_text=ascii(i).replace('\\u','uni').replace("'",'').replace("'",'') #將字符編碼轉爲字符串
        # 根據傳進來的class屬性值打開對應xml文件
        root = et.parse(path + f'{class_name}.xml')
        bihua=[]
        # 根據特殊編碼找到對應字體輪廓數據
        pts=root.findall(f'./glyf/TTGlyph[@name="{uni_text}"]/contour/pt')
        for pt in pts:
            bihua.append(pt.attrib)
        # 再根據得到的輪廓數據比對找到真正的字
        for j,true_text in zip(self.xyons,self.fonts):
            if j == bihua:
                break
        return true_text

    def get_data(self,class_names,uni_texts,total_texts):
        result=[]
        k=0 # 充當當前xpath語句下的所有的text值的遊標
        i=0 # 充當當前xpath語句下的所有的有class屬性值的text值的遊標
        while True:
            if uni_texts[i] == total_texts[k]:
                result.append(self.get_word(uni_texts[i],class_names[i]))
                if i<len(uni_texts)-1:
                    i+=1
                if len(result) != len(total_texts):
                    k+=1
                else:
                    break
            else:
                result.append(total_texts[k])
                if len(result) == len(total_texts):
                    break
                else:
                    k+=1
        return ''.join(result)
    
    def process_total_texts(self,total_texts):
        output=[]
        for i in total_texts:
            temp=i.strip()
            # 若含有空格字符則去掉
            if '\xa0' in temp:
                temp=temp.replace('\xa0','')
                if '\xa0' in temp:
                    temp=temp.replace('\xa0','')
            if temp != '':
                output.append(temp)
        return output
    
    def xpath(self,ress):
        # 傳進來的ress是一個elements列表
        output=[]
        for res in ress:
            class_names=res.xpath('.//*[(@class="num") or (@class="shopdesc") or (@class="review") or (@class="address") or (@class="dishname") or (@class="hours")]/@class').getall()
            uni_texts=res.xpath('.//*[(@class="num") or (@class="shopdesc") or (@class="review") or (@class="address") or (@class="dishname") or (@class="hours")]/text()').getall()
            total_texts=res.xpath('.//text()').getall()
            total_texts=self.process_total_texts(total_texts)
            data=self.get_data(class_names,uni_texts,total_texts)
            output.append(data)
        return output

if __name__ == '__main__':
	url='http://www.dianping.com/shop/112223644'
	headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36'}
	r=requests.get(url,headers=headers)
	response=Selector(r.text)
	perfect=Perfect()
	perfect.check_down_font()
	shop_name=response.xpath('//h1/text()').get()
	shop_info=perfect.xpath(response.xpath('//div[@class="brief-info"]'))[0]
	shop_address=perfect.xpath(response.xpath('//span[@id="address"]'))[0]
	shop_phone=perfect.xpath(response.xpath('//p[@class="expand-info tel"]'))[0]
	shop_open=perfect.xpath(response.xpath('//p[@class="info info-indent"]'))[0]
	user_names=response.xpath('//a[@class="name"]/text()').getall()
	user_comments=perfect.xpath(response.xpath('//p[@class="desc"]'))
	comments=dict(zip(user_names,user_comments))
	item={'商店名稱':shop_name,'商店信息':shop_info,'商店地址':shop_address,'商店電話':shop_phone,'營業時間':shop_open,'用戶評論':comments}
	item

輸出如下:

通過比對瀏覽器上的信息,完全一致!


參考鏈接
https://www.ituring.com.cn/book/tupubarticle/28992


寫在最後

因爲這本《python3反爬蟲原理與繞過實戰》書,讓我知道了以前沒有遇到過的反爬蟲策略,以及反反爬蟲策略。然而發現自己好像在爬蟲這方面其實還有很多的不足的,很多知識點是需要進一步學習與攻克的,因爲爬蟲涉及到前端、後端等等很多發麪的知識,故這條路還很長呀,要慢慢來才行哦。前路漫漫,不過未來可期!加油吧!

後面有時間我會繼續出反爬蟲系列的,敬請期待~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章