Selenium是一款強(qiáng)大的基于瀏覽器的開源自動(dòng)化測(cè)試工具,最初由 Jason Huggins 于 2004 年在 ThoughtWorks 發(fā)起,它提供了一套簡(jiǎn)單易用的 API,模擬瀏覽器的各種操作,方便各種 應(yīng)用的自動(dòng)化測(cè)試。它的取名很有意思,因?yàn)楫?dāng)時(shí)最流行的一款自動(dòng)化測(cè)試工具叫做 QTP,是由 Mercury 公司開發(fā)的商業(yè)應(yīng)用。Mercury 是化學(xué)元素汞,而 Selenium 是化學(xué)元素硒,汞有劇毒,而硒可以解汞毒,它對(duì)汞有拮抗作用。
Selenium 的核心組件叫做 Selenium-RC(Remote Control),簡(jiǎn)單來說它是一個(gè)代理服務(wù)器,瀏覽器啟動(dòng)時(shí)通過將它設(shè)置為代理,它可以修改請(qǐng)求響應(yīng)報(bào)文并向其中注入 Javascript,通過注入的 JS 可以模擬瀏覽器操作,從而實(shí)現(xiàn)自動(dòng)化測(cè)試。但是注入 JS 的方法存在很多限制,譬如無法模擬鍵盤和鼠標(biāo)事件,處理不了對(duì)話框,不能繞過 JavaScript 沙箱等等。就在這個(gè)時(shí)候,于 2006 年左右的工程師 Simon Stewart 發(fā)起了 WebDriver 項(xiàng)目,WebDriver 通過調(diào)用瀏覽器提供的原生自動(dòng)化 API 來驅(qū)動(dòng)瀏覽器,解決了 Selenium 的很多疑難雜癥。不過 WebDriver 也有它不足的地方,它不能支持所有的瀏覽器,需要針對(duì)不同的瀏覽器來開發(fā)不同的 WebDriver,因?yàn)椴煌臑g覽器提供的 API 也不盡相同,好在經(jīng)過不斷的發(fā)展,各種主流瀏覽器都已經(jīng)有相應(yīng)的 WebDriver 了。最終 Selenium 和 WebDriver 合并在一起,這就是 Selenium 2.0,有的地方也直接把它稱作 WebDriver。
一、Selenium 爬蟲入門
Selenium 的初衷是打造一款優(yōu)秀的自動(dòng)化測(cè)試工具,但是慢慢的人們就發(fā)現(xiàn),Selenium 的自動(dòng)化用來做爬蟲正合適。我們知道,傳統(tǒng)的爬蟲通過直接模擬 HTTP 請(qǐng)求來爬取站點(diǎn)信息,由于這種方式和瀏覽器訪問差異比較明顯,很多站點(diǎn)都采取了一些反爬的手段,而 Selenium 是通過模擬瀏覽器來爬取信息,其行為和用戶幾乎一樣,反爬策略也很難區(qū)分出請(qǐng)求到底是來自 Selenium 還是真實(shí)用戶。而且通過 Selenium 來做爬蟲,不用去分析每個(gè)請(qǐng)求的具體參數(shù),比起傳統(tǒng)的爬蟲開發(fā)起來更容易。Selenium 爬蟲唯一的不足是慢,如果你對(duì)爬蟲的速度沒有要求,那使用 Selenium 是個(gè)非常不錯(cuò)的選擇。Selenium 提供了多種語(yǔ)言的支持不論你是用哪種語(yǔ)言開發(fā)爬蟲,Selenium 都適合你。
我們第一節(jié)先通過 Python 學(xué)習(xí) Selenium 的基礎(chǔ)知識(shí),后面幾節(jié)再介紹我在使用 Selenium 開發(fā)瀏覽器爬蟲時(shí)遇到的一些問題和解決方法。
1.1 Hello World
一個(gè)最簡(jiǎn)單的 Selenium 程序像下面這樣:
from selenium import webdriver
browser = webdriver.Chrome()
browser.get('http://www.baidu.com/')
這段代碼理論上會(huì)打開 Chrome 瀏覽器,并訪問百度首頁(yè)。但事實(shí)上,如果你第一次使用 Selenium,很可能會(huì)遇到下面這樣的報(bào)錯(cuò):
selenium.common.exceptions.WebDriverException:
Message: 'chromedriver' executable needs to be in PATH.
Please see https://sites.google.com/a/chromium.org/chromedriver/home
報(bào)錯(cuò)提示很明確,要使用 Chrome 瀏覽器,必須得有 chromedriver,而且 chromedriver 文件位置必須得配置到 PATH 環(huán)境變量中。chromedriver 文件可以通過錯(cuò)誤提示中的地址下載。不過在生產(chǎn)環(huán)境,我并不推薦這樣的做法,使用下面的方法可以手動(dòng)指定 chromedriver 文件的位置:
from selenium import webdriver browser=webdriver.Chrome(executable_path="./drivers/chromedriver.exe")
browser.get('http://www.baidu.com/')
這里給出的例子是 Chrome 瀏覽器,Selenium 同樣可以驅(qū)動(dòng) Firefox、IE、Safari 等。這里列出了幾個(gè)流行瀏覽器webdriver的下載地址。Selenium 的官網(wǎng)也提供了大多數(shù)瀏覽器驅(qū)動(dòng)的下載信息,你可以參考 Third Party Drivers, Bindings, and Plugins 一節(jié)。
1.2 輸入和輸出
通過上面的一節(jié),我們已經(jīng)可以自動(dòng)的通過瀏覽器打開某個(gè)頁(yè)面了,作為爬蟲,我們還需要和頁(yè)面進(jìn)行更多的交互,歸結(jié)起來可以分為兩大類:輸入和輸出。
輸入指的是用戶對(duì)瀏覽器的所有操作,譬如上面的直接訪問某個(gè)頁(yè)面也是一種輸入,或者在輸入框填寫,下拉列表選擇,點(diǎn)擊某個(gè)按鈕等等;
輸出指的是根據(jù)輸入操作,對(duì)瀏覽器所產(chǎn)生的數(shù)據(jù)進(jìn)行解析,得到我們需要的數(shù)據(jù);這里 瀏覽器所產(chǎn)生的數(shù)據(jù) 不僅包括可見的內(nèi)容,如頁(yè)面上顯示的信息,也還包括不可見的內(nèi)容,如 HTML 源碼,甚至瀏覽器所發(fā)生的所有 HTTP 請(qǐng)求報(bào)文。
下面還是以百度為例,介紹幾種常見的輸入輸出方式。
1.2.1 輸入
我們打開百度進(jìn)行搜索,如果是人工操作,一般有兩種方式:第一種,在輸入框中輸入搜索文字,然后回車;第二種,在輸入框中輸入搜索文字,然后點(diǎn)擊搜索按鈕。Selenium 和人工操作完全一樣,可以模擬這兩種方式:
方式一 send keys with return
from selenium.webdriver.common.keys import Keys
kw = browser.find_element_by_id("kw")
kw.send_keys("Selenium", Keys.RETURN)
其中 find_element_by_id 方法經(jīng)常用到,它根據(jù)元素的 ID 來查找頁(yè)面某個(gè)元素。類似的方法還有 find_element_by_name、find_element_by_class_name、find_element_by_css_selector、find_element_by_xpath 等,都是用于定位頁(yè)面元素的。另外,也可以同時(shí)定位多個(gè)元素,例如 find_elements_by_name、find_elements_by_class_name 等,就是把 find_element 換成 find_elements,具體的 API 可以參考 Selenium 中文翻譯文檔中的 查找元素 一節(jié)。
通過 find_element_by_id 方法拿到元素之后,就可以對(duì)這個(gè)元素進(jìn)行操作,也可以獲取元素的屬性或者它的文本。kw 這個(gè)元素是一個(gè) input 輸入框,可以通過 send_keys 來模擬按鍵輸入。不僅可以模擬輸入可見字符,也可以模擬一些特殊按鍵,譬如回車 Keys.RETURN,可模擬的所有特殊鍵可以參考 這里。
針對(duì)不同的元素,有不同的操作,譬如按鈕,可以通過 click 方法來模擬點(diǎn)擊,如下。
方式二 send keys then click submit button
kw = browser.find_element_by_id("kw")
su = browser.find_element_by_id("su")
kw.send_keys("Selenium")
su.click()
如果這個(gè)元素是在一個(gè)表單(form)中,還可以通過 submit 方法來模擬提交表單。
方式三 send keys then submit form
kw = browser.find_element_by_id("kw")
kw.send_keys("Selenium")
kw.submit()
submit 方法不僅可以直接應(yīng)用在 form 元素上,也可以應(yīng)用在 form 元素里的所有子元素上,submit 會(huì)自動(dòng)查找離該元素最近的父 form 元素然后提交。這種方式是程序特有的,有點(diǎn)類似于直接在 Console 里執(zhí)行 $('form').submit() JavaScript 代碼。由此,我們引出第四種輸入方法,也是最最強(qiáng)大的輸入方法,可以說幾乎是無所不能,直接在瀏覽器里執(zhí)行 JavaScript 代碼:
方式四 execute javascript
browser.execute_script(
''' var kw = document.getElementById('kw');
var su = document.getElementById('su');
kw.value = 'Selenium';
su.click();
''')
這和方式二非常相似,但是要注意的是,方式四是完全通過 JavaScript 來操作頁(yè)面,所以靈活性是無限大的,幾乎可以做任何操作。除了這些輸入方式,當(dāng)然還有其他方式,譬如,先在輸入框輸入搜索文字,然后按 Tab 鍵將焦點(diǎn)切換到提交按鈕,然后按回車,原理都是大同小異,此處不再贅述,你可以自己寫程序試一試。
另外,對(duì)于 select 元素,Selenium 單獨(dú)提供了一個(gè)類 selenium.webdriver.support.select.Select 可以方便元素的選取。其他類型的元素,都可以通過上述四種方式來處理。
1.2.2 輸出
有輸入就有輸出,當(dāng)點(diǎn)擊搜索按鈕之后,如果我們要爬取頁(yè)面上的搜索結(jié)果,我們有幾種不同的方法。
方式一 parse page_source
html = browser.page_source
results = parse_html(html)
第一種方式最原始,和傳統(tǒng)爬蟲幾無二致,直接拿到頁(yè)面源碼,然后通過源碼解析出我們需要的數(shù)據(jù)。但是這種方式存在缺陷,如果頁(yè)面數(shù)據(jù)是通過 Ajax 動(dòng)態(tài)加載的,browser.page_source 獲取到的是最初返回的 HTML 頁(yè)面,這個(gè) HTML 頁(yè)面可能啥都沒有。這種情況,我們可以通過遍歷頁(yè)面元素來獲取數(shù)據(jù),如下:
方式二 find & parse elements
results = browser.find_elements_by_css_selector("#content_left .c-container")
for result in results:
link = result.find_element_by_xpath(".//h3/a")
print(link.text)
這種方式需要充分利用上面介紹的 查找元素 技巧,譬如這里如果要解析百度的搜索頁(yè)面,我們可以根據(jù) #content_left .c-container 這個(gè) CSS 選擇器定位出每一條搜索結(jié)果的元素節(jié)點(diǎn)。然后在每個(gè)元素下,通過 XPath .//h3/a 來取到搜索結(jié)果的標(biāo)題的文本。XPath 在定位一些沒有特殊標(biāo)志的元素時(shí)特別有用。
方式三 intercept & parse ajax
方式二在大多數(shù)情況下都沒問題,但是有時(shí)候還是有局限的。譬如頁(yè)面通過 Ajax 請(qǐng)求動(dòng)態(tài)加載,某些數(shù)據(jù)在 Ajax 請(qǐng)求的響應(yīng)中有,但在頁(yè)面上并沒有體現(xiàn),而我們恰恰想要爬取 Ajax 響應(yīng)中的那些數(shù)據(jù),這種情況上面兩種方式都無法實(shí)現(xiàn)。我們能不能攔截這些 Ajax 請(qǐng)求,并對(duì)其響應(yīng)進(jìn)行解析呢?這個(gè)問題我們放在后面一節(jié)再講。
1.3 處理 Ajax 頁(yè)面
上面也提到過,如果頁(yè)面上有 Ajax 請(qǐng)求,使用 browser.page_source 得到的是頁(yè)面最原始的源碼,無法爬到百度搜索的結(jié)果。事實(shí)上,不僅如此,如果你試過上面 方式二 find & parse elements 的例子,你會(huì)發(fā)現(xiàn)用這個(gè)方式程序也爬不到搜索結(jié)果。這是因?yàn)?browser.get() 方法并不會(huì)等待頁(yè)面完全加載完畢,而是等到瀏覽器的 onload 方法執(zhí)行完就返回了,這個(gè)時(shí)候頁(yè)面上的 Ajax 可能還沒加載完。如果你想確保頁(yè)面完全加載完畢,當(dāng)然可以用 time.sleep() 來強(qiáng)制程序等待一段時(shí)間再處理頁(yè)面元素,但是這種方法顯然不夠優(yōu)雅。或者自己寫一個(gè) while 循環(huán)定時(shí)檢測(cè)某個(gè)元素是否已加載完,這個(gè)做法也沒什么問題,但是我們最推薦的還是使用 Selenium 提供的 WebDriverWait 類。
WebDriverWait 類經(jīng)常和 expected_conditions 搭配使用,注意 expected_conditions 并不是一個(gè)類,而是一個(gè)文件,它下面有很多類,都是小寫字母,看起來可能有點(diǎn)奇怪,但是這些類代表了各種各樣的等待條件。譬如下面這個(gè)例子:
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions
WebDriverWait(browser, 10).until(
expected_conditions.presence_of_element_located((By.ID, "kw")))
代碼的可讀性很好,基本上能看明白這是在等待一個(gè) id 為 kw 的元素出現(xiàn),超時(shí)時(shí)間為 10s。不過代碼看起來還是怪怪的,往往我們會(huì)給 expected_conditions 取個(gè)別名,譬如 Expect,這樣代碼看起來更精簡(jiǎn)了:
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait as Wait
from selenium.webdriver.support import expected_conditions as ExpectWait(browser, 10).until(
Expect.presence_of_element_located((By.ID, "kw"))
)
我們?cè)僖砸粋€(gè)實(shí)際的例子來看看 expected_conditions 的強(qiáng)大之處,譬如在 途牛網(wǎng)上搜索上海到首爾的航班,這個(gè)頁(yè)面的航班結(jié)果都是以 Ajax 請(qǐng)求動(dòng)態(tài)加載的,我們?nèi)绾蔚却桨嗳考虞d完畢之后再開始爬取我們想要的航班結(jié)果呢?通過觀察可以發(fā)現(xiàn),在 “開始搜索”、“搜索中” 以及 “搜索結(jié)束” 這幾個(gè)階段,頁(yè)面顯示的內(nèi)容存在比較明顯的差異,如下圖所示:
我們就可以通過這些差異來寫等待條件。要想等到航班加載完畢,頁(yè)面上應(yīng)該會(huì)顯示 “共搜索xx個(gè)航班” 這樣的文本,而這個(gè)文本在 id 為 loadingStatus 的元素中。expected_conditions 提供的類 text_to_be_present_in_element 正滿足我們的要求,可以像下面這樣:
Wait(browser, 60).until(
Expect.text_to_be_present_in_element((By.ID, "loadingStatus"), u"共搜索")
)
下面是完整的代碼,可見一個(gè)瀏覽器爬蟲跟傳統(tǒng)爬蟲比起來還是有些差異的,瀏覽器爬蟲關(guān)注點(diǎn)更多的在頁(yè)面元素的處理上。
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait as Wait
from selenium.webdriver.support import expected_conditions as Expect
browser = webdriver.Chrome(executable_path="./drivers/chromedriver.exe")
browser.get('http://www.tuniu.com/flight/intel/sha-sel')
Wait(browser, 60).until(
Expect.text_to_be_present_in_element((By.ID, "loadingStatus"), u"共搜索")
)
flight_items = browser.find_elements_by_class_name("flight-item")
for flight_item in flight_items:
flight_price_row = flight_item.find_element_by_class_name("flight-price-row")
print(flight_price_row.get_attribute("data-no"))
除了上面提到的 presence_of_element_located 和 text_to_be_present_in_element 這兩個(gè)等待條件,Selenium 還提供了很多有用的條件類,參見 Selenium 的 WebDriver API。
二、Selenium 如何使用代理服務(wù)器?
通過上一節(jié)的介紹,相信你也可以用 Selenium 寫一個(gè)簡(jiǎn)單的爬蟲了。雖然 Selenium 完全模擬了人工操作,給反爬增加了點(diǎn)困難,但是如果網(wǎng)站對(duì)請(qǐng)求頻率做限制的話,Selenium 爬蟲爬快了一樣會(huì)遭遇被封殺,所以還得有代理。
代理是爬蟲開發(fā)人員永恒的話題。所以接下來的問題就是怎么在 Selelium 里使用代理,防止被封殺?我在很久之前寫過幾篇關(guān)于傳統(tǒng)爬蟲的博客,其中也講到了代理的話題,有興趣的同學(xué)可以參考一下 Java 和 HTTP 的那些事(二) 使用代理。
在寫代碼之前,我們要了解一點(diǎn),Selenium 本身是和代理沒關(guān)系的,我們是要給瀏覽器設(shè)置代理而不是給 Selenium 設(shè)置,所以我們首先要知道瀏覽器是怎么設(shè)置代理的。瀏覽器大抵有五種代理設(shè)置方式,第一種是直接使用系統(tǒng)代理,第二種是使用瀏覽器自己的代理配置,第三種通過自動(dòng)檢測(cè)網(wǎng)絡(luò)的代理配置,這種方式利用的是 WPAD 協(xié)議,讓瀏覽器自動(dòng)發(fā)現(xiàn)代理服務(wù)器,第四種是使用插件控制代理配置,譬如 Chrome 瀏覽器的 Proxy SwitchyOmega 插件,最后一種比較少見,是通過命令行參數(shù)指定代理。這五種方式并不是每一種瀏覽器都支持,而且設(shè)置方式可能也不止這五種,如果還有其他的方式,歡迎討論。
直接使用系統(tǒng)代理無需多講,這在生產(chǎn)環(huán)境也是行不通的,除非寫個(gè)腳本不斷的切換系統(tǒng)代理,或者使用自動(dòng)撥號(hào)的機(jī)器,也未嘗不可,但這種方式不夠 programmatically。而瀏覽器自己的配置一般來說基本上都會(huì)對(duì)應(yīng)命令行的某個(gè)參數(shù)開關(guān),譬如 Chrome 瀏覽器可以通過 --proxy-server 參數(shù)來指定代理:
chrome.exe http://www.ip138.com --proxy-server=127.0.0.1:8118
注:執(zhí)行這個(gè)命令之前,要先將現(xiàn)有的 Chrome 瀏覽器窗口全部關(guān)閉,如果你的 Chrome 安裝了代理配置的插件如 SwitchyOmega,還需要再加一個(gè)參數(shù) --disable-extensions 將插件禁用掉,要不然命令行參數(shù)不會(huì)生效。
2.1 通過命令行參數(shù)指定代理
使用 Selenium 啟動(dòng)瀏覽器時(shí),也可以指定瀏覽器的啟動(dòng)參數(shù)。像下面這樣即可:
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--proxy-server=127.0.0.1:8118')
browser = webdriver.Chrome(
executable_path="./drivers/chromedriver.exe",
chrome_options=chrome_options
)
browser.get('http://ip138.com')
這里的 --proxy-server 參數(shù)格式為 ip:port,注意它不支持這種帶用戶名密碼的格式 username:password@ip:port,所以如果代理服務(wù)器需要認(rèn)證,訪問網(wǎng)頁(yè)時(shí)就會(huì)彈出一個(gè)認(rèn)證對(duì)話框來。雖然使用 Selenium 也可以在對(duì)話框中填入用戶名和密碼,不過這種方式略顯麻煩,而且每次 Selenium 啟動(dòng)瀏覽器時(shí),都會(huì)彈出代理認(rèn)證的對(duì)話框。更好的做法是,把代理的用戶名和密碼都提前設(shè)置好,對(duì)于 Chrome 瀏覽器來說,我們可以通過它的插件來實(shí)現(xiàn)。
2.2 使用插件控制代理**
Chrome 瀏覽器下最流行的代理配置插件是 Proxy SwitchyOmega,我們可以先配置好 SwitchyOmega,然后 Selenium 啟動(dòng)時(shí)指定加載插件,Chrome 提供了下面的命令行參數(shù)用于加載一個(gè)或多個(gè)插件:
chrome.exe http://www.ip138.com --load extension=SwitchyOmega
不過要注意的是,--load-extension 參數(shù)只能加載插件目錄,而不能加載打包好的插件 *.crx 文件,我們可以把它當(dāng)成 zip 文件直接解壓縮到 SwitchyOmega 目錄即可。代碼如下:
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--load-extension=SwitchyOmega')
browser = webdriver.Chrome(
executable_path="./drivers/chromedriver.exe",
chrome_options=chrome_options
)
browser.get('http://ip138.com')
另外,Selenium 的 ChromeOptions 類還提供了一個(gè)方法 add_extension 用于直接加載未解壓的插件文件,如下:
chrome_options.add_extension('SwitchyOmega.crx')
這種做法應(yīng)該是可行的,不過我沒有具體去嘗試,因?yàn)檫@種做法依賴于 SwitchyOmega 的配置,如何在加載插件之前先把代理都配好?如何運(yùn)行時(shí)動(dòng)態(tài)的切換代理?這對(duì)爬蟲來說至關(guān)重要,以后有時(shí)候再去研究吧。不過很顯然,直接使用 SwitchyOmega 插件有點(diǎn)重了,我們能不能自己寫一個(gè)簡(jiǎn)單的插件來實(shí)現(xiàn)代理控制呢?
當(dāng)然可以。而且這個(gè)插件只需要兩行代碼即可。
關(guān)于 Chrome 插件的編寫,我之前有過兩篇博客:我的第一個(gè)Chrome擴(kuò)展:Search-faster 和 我的第二個(gè)Chrome擴(kuò)展:JSONView增強(qiáng)版,感興趣的同學(xué)可以先看看這兩篇了解下如何寫一個(gè) Chrome 插件。這里略過不提,我們這個(gè)插件需要有兩個(gè)文件,一個(gè)是 manifest.json 文件,為插件的清單文件,每個(gè)插件都要有,另一個(gè)是 background.js 文件,它是背景腳本,類似于后臺(tái)駐留進(jìn)程,它就是代理配置插件的核心。
下面我們就來看看這兩行代碼,第一行如下:
chrome.proxy.settings.set({
value: {
mode: "fixed_servers",
rules: {
singleProxy: {
scheme: "http",
host: "127.0.0.1",
port: 8118
},
bypassList: ["foobar.com"]
}
},
scope: "regular"
}, function() {});
chrome.proxy 是用于管理 Chrome 瀏覽器的代理服務(wù)器設(shè)置的 API,上面的代碼通過其提供的方法 chrome.proxy.settings.set() 設(shè)置了一個(gè)代理服務(wù)器地址,mode 的值為 fixed_servers 表示根據(jù)下面的 rules 來指定某個(gè)固定的代理服務(wù)器,代理類型可以是 HTTP 或 HTTPS,還可以是 SOCKS 代理。mode 的值還可以是 direct(無需代理),auto_detect(通過 WPAD 協(xié)議自動(dòng)檢測(cè)代理),pac_script(通過 PAC 腳本動(dòng)態(tài)選取代理)和 system(使用系統(tǒng)代理)。關(guān)于這個(gè) API 的詳細(xì)說明可以參看 Chrome 的 官方文檔,這里有一份 中文翻譯。
通過上面的代碼也只是設(shè)置了代理服務(wù)器的 IP 地址和端口而已,用戶名和密碼還沒有設(shè)置,這和使用命令行參數(shù)沒什么區(qū)別。所以還需要下面的第二行代碼:
chrome.webRequest.onAuthRequired.addListener(
function (details) {
return {
authCredentials: {
username: "username",
password: "password"
}
};
},
{ urls: ["<all_urls>"] },
[ 'blocking' ]
);
我們先看看下面這張圖,了解下 Chrome 瀏覽器接受網(wǎng)絡(luò)請(qǐng)求的整個(gè)流程,一個(gè)成功的請(qǐng)求會(huì)經(jīng)歷一系列的事件(圖片來源):
這些事件都是由 chrome.webRequest API 提供,其中的 onAuthRequired 最值得我們注意,它是用于代理身份認(rèn)證的關(guān)鍵。所有的事件都可以通過 addListener 方法注冊(cè)一個(gè)回調(diào)函數(shù)作為監(jiān)聽器,當(dāng)請(qǐng)求需要身份認(rèn)證時(shí),回調(diào)函數(shù)返回代理的用戶名和密碼。除了回調(diào)方法,addListener 第二個(gè)參數(shù)用于指定該代理適用于哪些 url,這里的 <all_urls> 是固定的特殊語(yǔ)法,表示所有的 url,第三個(gè)參數(shù)字符串 blocking 表示請(qǐng)求將被阻塞,回調(diào)函數(shù)將以同步的方式執(zhí)行。這個(gè) API 也可以參考 Chrome 的 官方文檔,這里是 中文翻譯。
綜上,我們就可以寫一個(gè)簡(jiǎn)單的代理插件了,甚至將插件做成動(dòng)態(tài)生成的,然后 Selenium 動(dòng)態(tài)的加載生成的插件。
三、Selenium 如何過濾非必要請(qǐng)求?
Selenium 配合代理,你的爬蟲幾乎已經(jīng)無所不能了。上面說過,Selenium 爬蟲雖然好用,但有個(gè)最大的特點(diǎn)是慢,有時(shí)候太慢了也不是辦法。由于每次打開一個(gè)頁(yè)面 Selenium 都要等待頁(yè)面加載完成,包括頁(yè)面上的圖片資源,JS 和 CSS 文件的加載,而且更頭疼的是,如果頁(yè)面上有一些墻外資源,比如來自 Google 或 Facebook 等站點(diǎn)的鏈接,如果不使用境外代理,瀏覽器要一直等到這些資源連接超時(shí)才算頁(yè)面加載完成,而這些資源對(duì)我們的爬蟲沒有任何用處。
我們能不能讓 Selenium 過濾掉那些我們不需要的請(qǐng)求呢?
Yi Zeng 在他的一篇博客 Exclude Selenium WebDriver traffic from Google Analytics 上總結(jié)了很多種方法來過濾 Google Analytics 的請(qǐng)求,雖然他的博客是專門針對(duì) Google Analytics 的請(qǐng)求,但其中有很多思路還是很值得我們借鑒的。其中有下面的幾種解決方案:
通過修改 hosts 文件,將 google.com、facebook.com 等重定向到本地,這種方法需要修改系統(tǒng)文件,不方便程序的部署,而且不能動(dòng)態(tài)的添加要過濾的請(qǐng)求;
禁用瀏覽器的 JavaScript 功能,譬如 Chrome 支持參數(shù) --disable-javascript 來禁用 JavaScript,但這種方法有很大的局限性,圖片和 CSS 資源還是沒有過濾掉,而且頁(yè)面上少了 JavaScript,可能站點(diǎn)的很多功能無法使用了;
使用瀏覽器插件,Yi Zeng 的博客中只提到了 Google-Analytics-Opt-out-Add-on 插件用于禁用 Google Analytics,實(shí)際上我們很容易想到 AdBlock 插件,這個(gè)插件用來過濾頁(yè)面上的一些廣告,這和我們想要的效果有些類似。我們可以自己寫一個(gè)插件,攔截不需要的請(qǐng)求,相信通過上一節(jié)的介紹,也可以做出來。
使用代理服務(wù)器 BrowserMob Proxy,通過代理服務(wù)器來攔截不需要的請(qǐng)求,除了 BrowserMob Proxy,還有很多代理軟件也具有攔截請(qǐng)求的功能,譬如 Fiddler 的 AutoResponder 或者 通過 whistle 設(shè)置 Rules 都可以攔截或修改請(qǐng)求;
這里雖然方法有很多,但我只推薦最后一種:使用代理服務(wù)器 BrowserMob Proxy,BrowserMob Proxy 簡(jiǎn)稱 BMP,可以這么說,BMP 絕對(duì)是為 Selenium 為生的,Selenium + BMP 的完美搭配,可以實(shí)現(xiàn)很多你絕對(duì)想象不出來的功能。
我之所以推薦 BMP,是由于 BMP 的理念非常巧妙,和傳統(tǒng)的代理服務(wù)器不一樣,它并不是一個(gè)簡(jiǎn)單的代理,而是一個(gè) RESTful 的代理服務(wù),通過 BMP 提供的一套 RESTful 接口,你可以創(chuàng)建或移除代理,設(shè)置黑名單或白名單,設(shè)置過濾器規(guī)則等等,可以說它是一個(gè)可編程式的代理服務(wù)器。BMP 是使用 Java 語(yǔ)言編寫的,它前后經(jīng)歷了兩個(gè)大版本的迭代,其核心也是從最初的 Jetty 演變?yōu)?LittleProxy,使得它更小巧和穩(wěn)定,你可以從 這里下載 BMP 的可執(zhí)行文件,在 Windows 系統(tǒng)上,我們直接雙擊執(zhí)行 bin 目錄下的 browsermob-proxy.bat 文件。
BMP 啟動(dòng)后,默認(rèn)在 8080 端口創(chuàng)建代理服務(wù),此時(shí) BMP 還不是一個(gè)代理服務(wù)器,需要你先創(chuàng)建一個(gè)代理:
curl -X POST http://localhost:8080/proxy
/proxy 接口發(fā)送 POST 請(qǐng)求,可以創(chuàng)建一個(gè)代理服務(wù)器。此時(shí),我們?cè)跒g覽器訪問 http://localhost:8080/proxy 這個(gè)地址,可以看到我們已經(jīng)有了一個(gè)代理服務(wù)器,端口號(hào)為 8081,現(xiàn)在我們就可以使用 127.0.0.1:8081 這個(gè)代理了。
接下來我們要把 Google 的請(qǐng)求攔截掉,BMP 提供了一個(gè) /proxy/[port]/blacklist 接口可以使用,如下:
curl -X PUT -d 'regex=.google.&status=404' http://localhost:8080/proxy/8081/blacklist
這樣所有匹配到 .google. 正則的 url,都將直接返回 404 Not Found。
知道了 BMP 怎么用,再接下來,就是編寫代碼了。當(dāng)然我們可以自己寫代碼來調(diào)用 BMP 提供的 RESTful 接口,不過俗話說得好,前人栽樹,后人乘涼,早就有人將 BMP 的接口封裝好給我們直接使用,譬如 browsermob-proxy-py 是 Python 的實(shí)現(xiàn),我們就來試試它。
from selenium import webdriver
from browsermobproxy import Server
server = Server("D:/browsermob-proxy-2.1.4/bin/browsermob-proxy")
server.start()
proxy = server.create_proxy()
proxy.blacklist(".google.", 404)
proxy.blacklist(".yahoo.", 404)
proxy.blacklist(".facebook.", 404)
proxy.blacklist(".twitter.", 404)
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument("--proxy-server={0}".format(proxy.proxy))
browser = webdriver.Chrome(
executable_path="./drivers/chromedriver.exe",
chrome_options = chrome_options
)
browser.get('http://www.flypeach.com/pc/hk')
server.stop()
browser.quit()
關(guān)鍵代碼在前面幾句,首先創(chuàng)建代理,再通過 proxy.blacklist() 將 google、yahoo、facebook、twitter 的資源攔截掉。后面的代碼和前一節(jié)的代理設(shè)置完全一樣。執(zhí)行程序,體會(huì)一下,現(xiàn)在這個(gè)頁(yè)面的打開速度快了多少?
BMP 不僅可以攔截請(qǐng)求,也可以修改請(qǐng)求,這對(duì)爬蟲來說可能意義不大,但在自動(dòng)化測(cè)試時(shí),可以通過它偽造測(cè)試數(shù)據(jù)還是很有意義的。它提供了兩個(gè)接口
/proxy/[port]/filter/request 和 /proxy/[port]/filter/response 用于修改 HTTP 的請(qǐng)求和響應(yīng),具體的用法可以參考 官網(wǎng)的文檔,此處略過。
proxy.request_interceptor(
'''
request.headers().remove('User-Agent');
request.headers().add('User-Agent', 'My-Custom-User-Agent-String 1.0');
'''
)
proxy.response_interceptor(
'''
if (messageInfo.getOriginalUrl().contains("remote/searchFlights")) {
contents.setTextContents('Hello World');
}
'''
)
四、Selenium 如何爬取 Ajax 請(qǐng)求?
到這里,問題變得越來越有意思了。而且我們發(fā)現(xiàn),用 Selenium 做爬蟲,中途確實(shí)會(huì)遇到各種各樣的問題,但隨著問題的發(fā)現(xiàn)到解決,我們花在 Selenium 上面的時(shí)間越來越少了,更多的是在研究其他的東西,如瀏覽器的特性,瀏覽器插件的編寫,可編程式的代理服務(wù)器,以此來輔助 Selenium 做的更好。
還記得前面提到的一個(gè)問題嗎?如果要爬取的內(nèi)容在 Ajax 請(qǐng)求的響應(yīng)中,而在頁(yè)面上并沒有體現(xiàn),這種情況該如何爬取呢?我們可以直接爬 Ajax 請(qǐng)求嗎?事實(shí)上,我們很難做到,但不是做不到。
通過上一節(jié)對(duì) BMP 的介紹,我們了解到 BMP 可以攔截并修改請(qǐng)求的報(bào)文,我們可以進(jìn)一步猜想,既然它可以修改報(bào)文,那肯定也可以拿到報(bào)文,只是這個(gè)報(bào)文我們的程序該如何得到?上一節(jié)我們提到了兩個(gè)接口 /proxy/[port]/filter/request 和 /proxy/[port]/filter/response,它們可以接受一段 JS 代碼來修改 HTTP 的請(qǐng)求和響應(yīng),其中我們可以通過 contents.getTextContents() 來訪問響應(yīng)的報(bào)文,只是這段代碼運(yùn)行在遠(yuǎn)程服務(wù)器上,和我們的代碼在兩個(gè)完全不同的世界里,如何把它傳給我們呢?而且,這段 JS 代碼的限制非常嚴(yán)格,我們想通過這個(gè)地方拿到這個(gè)報(bào)文幾乎是不可能的。
但,路總是有的。
我們回過頭來看 BMP 的文檔,發(fā)現(xiàn) BMP 提供了兩種模式供我們使用:獨(dú)立模式(Standalone)和 嵌入模式(Embedded Mode)。獨(dú)立模式就是像上面那樣,BMP 作為一個(gè)獨(dú)立的應(yīng)用服務(wù),我們的程序通過 RESTful 接口與其交互。而嵌入模式則不需要下載 BMP 可執(zhí)行文件,直接通過包的形式引入到我們的程序中來。可惜的是,嵌入模式只支持 Java 語(yǔ)言,但這也聊勝于無,于是我使用 Java 寫了個(gè)測(cè)試程序嘗試了一把。
首先引入 browsermob-core 包,
<dependency>
<groupId>net.lightbody.bmp</groupId>
<artifactId>browsermob-core</artifactId>
<version>2.1.5</version>
</dependency>
然后參考官網(wǎng)文檔寫下下面的代碼(完整代碼見 這里),這里就可以看到嵌入模式的好處了,用于 BMP 攔截處理的代碼和我們自己的代碼處于同一個(gè)環(huán)境下,而且 Java 語(yǔ)言具有閉包的特性,我們可以很簡(jiǎn)單的取到 Ajax 請(qǐng)求的響應(yīng)報(bào)文:
BrowserMobProxyproxyServer=newBrowserMobProxyServer();
proxyServer.start(0);
proxyServer.addRequestFilter((request,contents,messageInfo)->{
System.out.println("請(qǐng)求開始:"+messageInfo.getOriginalUrl());
returnnull;
});
StringajaxContent=null;
proxyServer.addResponseFilter((response,contents,messageInfo)->{
System.out.println("請(qǐng)求結(jié)束:"+messageInfo.getOriginalUrl());
if(messageInfo.getOriginalUrl().contains("ajax")){
ajaxContent=contents.getTextContents();
}
});
如果你是個(gè) .Net guy,可以使用 Fiddler 提供的 FiddlerCore,F(xiàn)iddlerCore 就相當(dāng)于 BMP 的嵌入模式,和這里的方法類似。這里有一篇很好的文章講解了如何使用 .Net 和 FiddlerCore 攔截請(qǐng)求。
既然在 Java 環(huán)境下解決了這個(gè)問題,那么 Python 應(yīng)該也沒問題,但是 BMP 的嵌入模式并不支持 Python 怎么辦呢?于是我一直在尋找一款基于 Python 的能替代 BMP 的工具,可惜一直不如愿,未能找到滿意的。到最后,我?guī)缀跻陆Y(jié)論:Python + Selenium 很難實(shí)現(xiàn) Ajax 請(qǐng)求的爬取。
天無絕人之路,直到我遇到了 har。
有一天我靜下心來把 BMP 的文檔翻來覆去看了好幾遍,之前我看文檔的習(xí)慣都是用時(shí)再查,但這次把 BMP 的文檔從頭到尾看了幾遍,也是希望能從中尋找點(diǎn)蛛絲馬跡。而事實(shí)上,還真被我發(fā)現(xiàn)了點(diǎn)什么。因?yàn)?Python 只能通過 RESTful 接口與 BMP 交互,那么每一個(gè)接口我都不能放過,有一個(gè)接口引起了我的注意:/proxy/[port]/har。
這個(gè)接口雖然之前也掃過幾眼,但當(dāng)時(shí)并不知道這個(gè) har 是什么意思,所以都是一掠而過。但那天心血來潮,特意去查了一下 har 的資料,才發(fā)現(xiàn)這是一種特殊的 JSON 格式的歸檔文件。HAR 全稱 HTTP Archive Format,通常用于記錄瀏覽器訪問網(wǎng)站的所有交互請(qǐng)求,絕大多數(shù)瀏覽器和 Web 代理都支持這種格式的歸檔文件,用于分析 HTTP 請(qǐng)求,因?yàn)閺V泛的應(yīng)用,W3C 甚至還提出 HAR 的規(guī)范,目前還在草稿階段。
/proxy/[port]/har 接口用于創(chuàng)建一份新的 har 文件,Selenium 啟動(dòng)瀏覽器后所有的請(qǐng)求都將被記錄到這份 har 文件中,然后通過 GET 請(qǐng)求,可以獲取到這份 har 文件的內(nèi)容(JSON 格式)。har 文件的內(nèi)容類似于下面這樣:
{
"log": {
"version" : "1.2",
"creator" : {},
"browser" : {},
"pages": [],
"entries": [],
"comment": ""
}
}
其中 entries 數(shù)組包含了所有 HTTP 請(qǐng)求的列表,默認(rèn)情況下 BMP 創(chuàng)建的 har 文件并不包含請(qǐng)求的響應(yīng)內(nèi)容,我們可以通過 captureContent 參數(shù)來讓 BMP 記錄響應(yīng)內(nèi)容:
curl -X PUT -d 'captureContent=true' http://localhost:8080/proxy/8081/har
萬(wàn)事俱備,只欠東風(fēng)。我們開始寫代碼,首先通過 proxy.new_har() 創(chuàng)建一份 har 文件:
proxy.new_har(options={'captureContent': True })
然后啟動(dòng)瀏覽器,訪問要爬取的頁(yè)面,等待頁(yè)面加載結(jié)束,這時(shí)我們就可以通過 proxy.har 來訪問 har 文件中的請(qǐng)求報(bào)文了(完整代碼在 這里):
for entry in proxy.har['log']['entries']:
if 'remote/searchFlights' in entry['request']['url']:
result = json.loads(entry['response']['content']['text'])
for key, item in result['data']['flightInfo'].items():
print(key)
總結(jié)
這篇博客總結(jié)了 Selenium 的一些基礎(chǔ)語(yǔ)法,并嘗試使用 Python + Selenium 開發(fā)瀏覽器爬蟲。本文還分享了我在實(shí)際開發(fā)過程中遇到的幾個(gè)常見問題,并提供了一種或多種解決方案,包括代理的使用,攔截瀏覽器請(qǐng)求,爬取 Ajax 請(qǐng)求等等。實(shí)踐出真知,通過一系列問題的提出,到研究,到解決,我學(xué)習(xí)到了非常多的東西。不僅意識(shí)到知識(shí)廣度的重要性,而且更重要的是知識(shí)的聚合和熔煉。我一直認(rèn)為知識(shí)的廣度比深度更重要,只有你懂的越多,你才有可能接觸更多的東西,你的思路才更放得開;深度固然也重要,但往往會(huì)讓人局限于自己的漩渦之中。但知識(shí)的廣度不是天馬行空,需要不斷的總結(jié)提煉,融會(huì)貫通,形成自己的知識(shí)體系,這樣才不至于被繁多的知識(shí)點(diǎn)所困擾。
另外,我也意識(shí)到閱讀項(xiàng)目文檔的重要性,心平氣和的將項(xiàng)目文檔從頭到尾閱讀一遍,遇到不懂的,就去查找資料,而不是只挑自己知道或感興趣的,這樣會(huì)得到意想不到的收獲。