一、引言
爬取過大眾點評的朋友應該會遇到這樣的問題,在網頁中看起來正常的文字,在其源代碼中變成了下面這樣:
究其原因,是因為大眾點評在內容上設置的特別的反爬機制,與某些網站替換底層字體文件不同,大眾點評使用隨機替換的SVG圖片來替換對應位置的漢字內容,使得我們使用常規的手段無法獲取其網頁中完整的文字內容,經過觀察我發現,所有可以被SVG圖像替換的文字都保存在下圖所示的地址中:
打開該頁面后可以發現其包含了所有可以被SVG替換的文字:
在查閱了他人針對該問題提出的相關文章后,獲悉他們使用的方法是先找到源代碼中SVG圖像對應的<span>標簽,其屬性class與下圖紅框中所示第一個以及第二個px值存在一一映射關系,且該關系全量保存在旁邊對應的css中:
右鍵該鏈接,選擇open in new tab,在跳轉的新頁面中便隱藏了全量的class屬性與兩個對應的px之間的映射關系:
按照前人的經驗,這兩個px通過一個公式與之前的SVG界面中所有漢字的行列位置構建起一一對應關系,但他們的做法是自己去手動猜測規則,建立公式從而破解從class屬性到SVG文字行列位置的一一映射關系,但這樣的方式顯然已經被大眾點評后台人員知曉,於是乎,更變態的是,他們這套映射規則幾乎每天都會發生一次更新,至少我在寫這篇文章的前一天遇到的情況,與今天所遭遇的情況完全不同,這就使得前人總結的那套靠腦力去猜測的方法吃力不討好,於是我摒棄了去猜測規則,而是選擇去學習規則,即利用機器學習算法來解決這個看起來較為棘手的問題;
二、基於決策樹分類器的破解方法
這里我選擇使用較為經典的CART分類樹來訓練算法,從而實現對其映射規則的學習,在訓練算法前,我們需要收集適量的樣本數據來構造帶標簽的訓練集,從而支撐之后的有監督學習過程;
2.1 收集訓練數據
通過觀察,我發現大眾點評的頁面中被SVG替換的文字並不確定,即每一次刷新頁面,都可能有新的文字被替換成SVG,舊的SVG圖像被還原為文字,借助這個機制,我們可以通過對某一確定的頁面多次刷新,每次用正則提取評論內容標簽下,所有符合單個漢字格式條件和<span class="xxxxx"></span>格式條件的片段,用下面的正則就可以實現:
'(<span class="[a-z0-9]+">)|([\u4e00-\u9fa5]{1})'
每次將符合上述任一條件的一個片段按照其在整段文字中出現的順序拼接起來,構造位置一一對應的編碼列表和文字列表,並在重復的頁面刷新過程中,通過識別從SVG圖像恢復成普通文字的現象,得到漢字與其class編碼的一一對應關系,再將這些已證實對應關系的漢字-編碼作為我們構造訓練集的基礎,自變量為通過該文字編碼在CSS頁面中索引到的兩個px值(用正則即可輕松實現),因變量為該文字在SVG頁面中對應的行列位置,因為每行的文字數量不太一致,所以這里需要寫一個簡單的算法從SVG頁面源代碼中抽取每個漢字的行列位置並保存起來,以上步驟我的代碼實現如下,這里為了跳過模擬登陸,我選擇了在本地Chrome瀏覽器登錄自己的大眾點評賬號並保持登錄,再利用selenium來掛載本地瀏覽器配置文件,從而達到自動登錄的目的:
'''這個腳本用於對大眾點評店鋪評論板塊下所有被SVG圖像替換加密的漢字進行破解'''
from bs4 import BeautifulSoup
from tqdm import tqdm
import os
from sklearn.tree import DecisionTreeClassifier
import re
import time
import requests
from selenium import webdriver
import numpy as np
import pandas as pd
def OfferLocalBrowser(headless=False):
'''
這個函數用於提供自動登錄大眾點評的Chrome瀏覽器
:param headless: 是否使用無頭Chrome
:return: 返回瀏覽器對象
'''
option = webdriver.ChromeOptions()
option.add_argument(r'user-data-dir=C:\Users\hp\AppData\Local\Google\Chrome\User Data')
if headless:
option.add_argument('--headless')
browser = webdriver.Chrome(options=option)
return browser
def CollectDataset(targetUrl,low=3,high=6,page=3,refreshTime=3): ''' :param targetUrl: 傳入可翻頁的任意商鋪評論頁面地址 :param low: 設置隨機睡眠防ban的隨機整數下限 :param high: 設置隨機睡眠防ban的隨機整數上限 :param page: 設置最大翻頁次數 :param refreshTime: 設置每個頁面重復刷新的時間 :return: 返回收集到的漢字列表和編碼列表 ''' '''初始化用於存放所有采集到的樣本詞和對應的樣本詞編碼的列表,CL用於存放所有編碼,WL用於存放所有詞,二者順序一一對應''' CL,WL = [],[] browser = OfferLocalBrowser(headless=False) for p in tqdm(range(1,page+1)): for r in range(refreshTime): '''訪問目標網頁''' html = browser.get(url=targetUrl.format(p)) if '3s 未完成驗證,請重試。' in str(browser.page_source): ii = input() '''將原始網頁內容解碼''' html = browser.page_source '''解析網頁內容''' obj = BeautifulSoup(html,'lxml') '''提取評論部分內容以方便之后對評論漢字和SVG圖像對應編碼的提取''' raw_comment = obj.find_all('div',{'class':'review-words Hide'}) '''初始化列表容器以有順序地存放符合漢字或SVG標簽格式的內容''' base_Comment = [] '''利用正則提取符合漢字內容規則的元素''' firstList = re.findall('(<span class="[a-z0-9]+">)|([\u4e00-\u9fa5]{1})',str(raw_comment)) '''構造該頁面中長度守恆的評論片段列表''' actualList = [] '''按順序將所有漢字片段和<span>標簽片段拼接在一起''' for i in range(len(firstList)): for j in range(2): if firstList[i][j] != '': actualList.append(firstList[i][j]) '''打印當前界面所有評論片段的長度''' print(len(actualList)) '''在每個頁面的第一次訪問時初始化漢字列表和編碼列表''' if r == 0: wordList = ['' for i in range(len(actualList))] codeList = ['' for i in range(len(actualList))] '''將actualList中粗糙的<span>片段清洗成純粹的編碼片段,漢字部分則原封不定保留,並分別更新wordList和codeList''' for index in range(len(actualList)): if '<' in actualList[index]: codeList[index] = re.findall('class="([a-z0-9]+)"',actualList[index])[0] else: wordList[index] = actualList[index] '''隨機睡眠防ban''' time.sleep(np.random.randint(low,high)) '''將結束重復采集的當前頁面中發現的所有漢字-編碼對應規則列表與先前的規則列表合並''' CL.extend(codeList) WL.extend(wordList) print('總列表長度:{}'.format(len(CL))) browser.quit() return WL,CL
2.2 數據預處理
通過上面的步驟我們已經得到朴素的漢字-編碼樣本,接下來我們將其與SVG頁面內容和CSS頁面內容串聯起來,從而構造能夠輸入決策樹分類器進行訓練的數據形式,這部分的主要代碼如下,因為在最開始我並沒有確定因變量到底是哪幾個,於是下面的代碼中我采集了SVG頁面中每個文字的行下標,列下標:
def CreateXandY(wordList,codeList,cssUrl,SvgUrl): ''' 這個函數用於傳入朴素的漢字列表、編碼列表、CSS頁面地址,SVG頁面地址來輸出規整的numpy多維數組格式的自變量X,以及標簽Y :param wordList: 漢字列表 :param codeList: 編碼列表 :param cssUrl: CSS頁面地址 :param SvgUrl: SVG頁面地址 :return: 返回自變量X,因變量Y ''' def GetSvgWordIpx(SvgUrl=SvgUrl): ''' 這個函數用於爬取SVG頁面,並返回所需內容 :param SvgUrl: SVG頁面地址 :return: 單個漢字為鍵,上面所列四個屬性為漢字鍵對應嵌套的字典中對應值的字典文件 ''' '''訪問SVG頁面''' SvgWord = requests.get(SvgUrl).content.decode() '''初始化漢字-候選因變量字典''' Svg2Label = {} '''提取SVG頁面中所有漢字所在的text標簽內容列表,每個列表對應頁面中一行文字''' rawList = re.findall('[\u4e00-\u9fa5]+', SvgWord) '''抽取每個漢字及其對應的四個候選因變量''' for row in range(len(rawList)): wordPreList = re.findall('[\u4e00-\u9fa5]{1}', rawList[row]) for word in wordPreList: Svg2Label[word] = { 'RowIndex': [], 'ColIndex': [] } Svg2Label[word]['RowIndex'] = row + 1 Svg2Label[word]['ColIndex'] = wordPreList.index(word) + 1 return Svg2Label '''訪問CSS頁面''' CodeWithIpx = requests.get(cssUrl).content.decode() '''初始化編碼-px值字典''' code2ipx = {} '''初始化針對樣本數據的編碼-漢字字典''' code2word = {} '''從樣本中抽取采集到的確切的漢字-編碼關系''' for code, word in tqdm(zip(codeList, wordList)): if code != '' and word != '': code2ipx[code] = re.search( '.%s{background:-(.*?).0px -(.*?).0px;}' % code, CodeWithIpx).groups() code2word[code] = word Svg2Label = GetSvgWordIpx() '''生成自變量和因變量''' X = [] for key, value in code2ipx.items(): X.append([int(value[0]), int(value[1])]) X = np.array(X) Y = [] for key, value in code2ipx.items(): Y.append([Svg2Label[code2word[key]]['RowIndex'], Svg2Label[code2word[key]]['ColIndex']]) Y = np.array(Y) return X,Y,Svg2Label,CodeWithIpx
2.3 訓練決策樹分類模型
通過上面的工作,我們成功構造出規整的訓練集,考慮到需要學習到的映射關系較為簡單,我們分別構造因變量為行下標、因變量為列下標的模型,並直接用全部數據進行訓練(最開始我有想過過擬合的問題,但后面發現這里的映射規則非常簡單,甚至可能是線性的,因此這里直接這樣雖然顯得不嚴謹,但經過后續測試發現這種方式最為簡單高效),具體代碼如下:
def GetModels(X,Y): ''' :param X: 因變量 :param Y: 自變量 :return: 用於預測行下標的模型1和預測列下標的模型2 ''' '''這個模型的因變量為對應漢字的行下標''' model1 = DecisionTreeClassifier().fit(X, Y[:, 0]) '''這個模型的因變量是對應漢字的列下標''' model2 = DecisionTreeClassifier().fit(X, Y[:, 1])return model1,model2
接下來我們來寫用於掛載模型並對漢字和SVG標簽混雜格式的字符串進行預測解碼的函數:
def Translate(s,baseDF,model1,model2): ''' 這個函數用於對漢字和SVG標簽格式混雜的字符串進行預測解碼 :param s: 待解碼的字符串 :param baseDF: 存放所有漢字與其行列下標的數據框 :param model1: 模型1 :param model2: 模型2 :return: 預測解碼結果 ''' result = '' for ele in s: for u in range(2): if ele[u] != '' and '<' in ele[u]: row_ = model1.predict(np.array( [int(re.search('.%s{background:-(.*?).0px -(.*?).0px;}' % re.search('<span class="([a-z0-9]+)">',ele[u]).group(1), CodeWithIpx).groups()[i]) for i in range(2)]).reshape(1, -1)) col_ = model2.predict(np.array( [int(re.search('.%s{background:-(.*?).0px -(.*?).0px;}' % re.search('<span class="([a-z0-9]+)">',ele[u]).group(1), CodeWithIpx).groups()[i]) for i in range(2)]).reshape(1, -1)) answer = baseDF['字符'][(baseDF['Row'] == row_.tolist()[0]) & (baseDF['Col'] == col_.tolist()[0])].tolist()[0] result += answer else: result += ele[u] return result
其中baseDF是利用之前從SVG頁面抽取的字典中得到的字符串,格式如下:
baseDF = pd.DataFrame({'字符': [key for key in Svg2Label.keys()], 'Row': [Svg2Label[key]['RowIndex'] for key in Svg2Label.keys()], 'Col': [Svg2Label[key]['ColIndex'] for key in Svg2Label.keys()]})
至此,我們所有需要的功能都以模塊化的方式編寫完成,下面我們來對任意挑選的頁面進行測試;
2.4 測試
這里我們挑選某火鍋店的前三頁評論,每個頁面重復刷新三次,用於采集訓練數據,並在某生鮮店鋪任選的某頁評論上進行測試,代碼如下:
'''測試''' wordList,codeList = CollectDataset(targetUrl = 'http://www.dianping.com/shop/72452707/review_all/p{}?queryType=sortType&queryVal=latest', low = 3, high = 6, page = 3, refreshTime = 3)
'''注意,這里CSS頁面地址和SVG頁面地址每天都在變動''' X,Y,Svg2Label,CodeWithIpx = CreateXandY(wordList=wordList,codeList=codeList, cssUrl = 'http://s3plus.meituan.net/v1/mss_0a06a471f9514fc79c981b5466f56b91/svgtextcss/c26b1e06f361cadaa823f1b76642e534.css', SvgUrl = 'http://s3plus.meituan.net/v1/mss_0a06a471f9514fc79c981b5466f56b91/svgtextcss/d6a6b2d601063fb185d7b89931259d79.svg') model1,model2 = GetModels(X,Y) browser = OfferLocalBrowser() browser.get('http://www.dianping.com/shop/124475710/review_all?queryType=sortType&&queryVal=latest') obj = BeautifulSoup(browser.page_source,'lxml') rawCommentList = obj.find_all('div',{'class':'review-words'}) baseDF = pd.DataFrame({'字符': [key for key in Svg2Label.keys()], 'Row': [Svg2Label[key]['RowIndex'] for key in Svg2Label.keys()], 'Col': [Svg2Label[key]['ColIndex'] for key in Svg2Label.keys()]}) for i in range(len(rawCommentList)): s = re.findall('(<span class="[a-z0-9]+">)|([\u4e00-\u9fa5]{1})',str(rawCommentList[i])) print(Translate(s,baseDF,model1,model2))
解碼效果如下,我特意選擇在與火鍋店評論相差很遠的生鮮類店鋪下進行測試,以避免潛在的過擬合現象干擾,測試效果如下,從而證明了我們的分類器在對規則學習上的成功(大眾點評的朋友們該更新加密算法了)
2.5 注意事項
需要注意的是,大眾點評文字反爬中涉及到的SVG頁面和CSS頁面每天都會更新,我嘗試過可以用正則從頁面中抽取SVG地址,但CSS地址暫時不知道怎么抽取,哪位老哥如果知道還請指導一下,因此需要在爬取前填入自己手動復制下來的SVG頁面和CSS頁面地址。
以上就是本文全部內容,如有疑問歡迎評論區討論,本文由博客園費弗里原創,首發於博客園,轉載請注明出處。