使用爬蟲技術實現 Web 頁面資源可用性檢測
對于電商類型和內容服務類型的網站,經常會出現因為配置錯誤造成頁面鏈接無法訪問的情況(404)。
顯然,要確保網站中的所有鏈接都具有可訪問性,通過人工進行檢測肯定是不現實的,常用的做法是使用爬蟲技術定期對網站進行資源爬取,及時發現訪問異常的鏈接。
對于網絡爬蟲,當前市面上已經存在大量的開源項目和技術討論的文章。不過,感覺大家普遍都將焦點集中在爬取效率方面,例如當前就存在大量討論不同并發機制哪個效率更高的文章,而在爬蟲的其它特性方面探討的不多。
個人認為,爬蟲的核心特性除了 快 ,還應該包括 全 和 穩 ,并且從重要性的排序來看, 全 、 穩 、 快 應該是從高到低的。
全 排在第一位,是因為這是爬蟲的基本功能,若爬取的頁面不全,就會出現信息遺漏的情況,這種情況肯定是不允許的;而 穩 排在第二位,是因為爬蟲通常都是需要長期穩定運行的,若因為策略處理不當造成爬蟲運行過程中偶爾無法正常訪問頁面,肯定也是無法接受的;最后才是 快 ,我們通常需要爬取的頁面鏈接會非常多,因此效率就很關鍵,但這也必須建立在 全 和 穩 的基礎上。
當然,爬蟲本身是一個很深的技術領域,我接觸的也只是皮毛。本文只針對使用爬蟲技術實現 Web 頁面資源可用性檢測的實際場景,詳細剖析下其中涉及到的幾個技術點,重點解決如下幾個問題:
- 全:如何才能爬取網站所有的頁面鏈接?特別是當前許多網站的頁面內容都是要靠前端渲染生成的,爬蟲要如何支持這種情況?
- 穩:很多網站都有訪問頻率限制,若爬蟲策略處理不當,就常出現 403 和 503 的問題,該種問題要怎么解決?
- 快:如何在保障爬蟲功能正常的前提下,盡可能地提升爬蟲效率?
爬蟲實現前端頁面渲染
在早些年,基本上絕大多數網站都是通過后端渲染的,即在服務器端組裝形成完整的 HTML 頁面,然后再將完整頁面返回給前端進行展現。而近年來,隨著 AJAX 技術的不斷普及,以及 AngularJS 這類 SPA 框架的廣泛應用,前端渲染的頁面越來越多。
不知大家有沒有聽說過,前端渲染相比于后端渲染,是不利于進行 SEO 的,因為對爬蟲不友好。究其原因,就是因為前端渲染的頁面是需要在瀏覽器端執行 JavaScript 代碼(即 AJAX 請求)才能獲取后端數據,然后才能拼裝成完整的 HTML 頁面。
針對這類情況,當前也已經有很多解決方案,最常用的就是借助 PhantomJS、 puppeteer 這類 Headless 瀏覽器工具,相當于在爬蟲中內置一個瀏覽器內核,對抓取的頁面先渲染(執行 Javascript 腳本),然后再對頁面內容進行抓取。
不過,要使用這類技術,通常都是需要使用 Javascript 來開發爬蟲工具,對于我這種寫慣了 Python 的人來說的確有些痛苦。
直到某一天, kennethreitz 大神發布了開源項目 requests-html ,看到項目介紹中的那句 Full JavaScript support! 時不禁熱淚盈眶,就是它了!該項目在 GitHub 上發布后不到三天,star 數就達到 5000 以上,足見其影響力。
requests-html 為啥會這么火?
寫過 Python 的人,基本上都會使用 requests 這么一個 HTTP 庫,說它是最好的 HTTP 庫一點也不夸張(不限編程語言),對于其介紹語 HTTP Requests for Humans 也當之無愧。也是因為這個原因, Locust 和 HttpRunner 都是基于 requests 來進行開發的。
而 requests-html ,則是 kennethreitz 在 requests 的基礎上開發的另一個開源項目,除了可以復用 requests 的全部功能外,還實現了對 HTML 頁面的解析,即支持對 Javascript 的執行,以及通過 CSS 和 XPath 對 HTML 頁面元素進行提取的功能,這些都是編寫爬蟲工具非常需要的功能。
在實現 Javascript 執行方面, requests-html 也并沒有自己造輪子,而是借助了 pyppeteer 這個開源項目。還記得前面提到的 puppeteer 項目么,這是 GoogleChrome 官方實現的 Node API ;而 pyppeteer 這個項目,則相當于是使用 Python 語言對 puppeteer 的非官方實現,基本具有 puppeteer 的所有功能。
理清了以上關系后,相信大家對 requests-html 也就有了更好的理解。
在使用方面, requests-html 也十分簡單,用法與 requests 基本相同,只是多了 render 功能。
from requests_html import HTMLSession
session = HTMLSession()
r = session.get('http://python-requests.org')
r.html.render()
在執行 render() 之后,返回的就是經過渲染后的頁面內容。
爬蟲實現訪問頻率控制
為了防止流量攻擊,很多網站都有訪問頻率限制,即限制單個 IP 在一定時間段內的訪問次數。若超過這個設定的限制,服務器端就會拒絕訪問請求,即響應狀態碼為 403(Forbidden)。
這用來應對外部的流量攻擊或者爬蟲是可以的,但在這個限定策略下,公司內部的爬蟲測試工具同樣也無法正常使用了。針對這個問題,常用的做法就是在應用系統中開設白名單,將公司內部的爬蟲測試服務器 IP 加到白名單中,然后針對白名單中的 IP 不做限制,或者提升限額。但這同樣可能會出現問題。因為應用服務器的性能不是無限的,假如爬蟲的訪問頻率超過了應用服務器的處理極限,那么就會造成應用服務器不可用的情況,即響應狀態碼為 503(Service Unavailable Error)。
基于以上原因,爬蟲的訪問頻率應該是要與項目組的開發和運維進行統一評估后確定的;而對于爬蟲工具而言,實現對訪問頻率的控制也就很有必要了。
那要怎樣實現訪問頻率的控制呢?
我們可以先回到爬蟲本身的實現機制。對于爬蟲來說,不管采用什么實現形式,應該都可以概括為生產者和消費者模型,即:
- 消費者:爬取新的頁面
- 生產者:對爬取的頁面進行解析,得到需要爬取的頁面鏈接
對于這種模型,最簡單的做法是使用一個 FIFO 的隊列,用于存儲未爬取的鏈接隊列(unvisited_urls_queue)。不管是采用何種并發機制,這個隊列都可以在各個 worker 中共享。對于每一個 worker 來說,都可以按照如下做法:
- 從 unvisited_urls_queue 隊首中取出一個鏈接進行訪問;
- 解析出頁面中的鏈接,遍歷所有的鏈接,找出未訪問過的鏈接;
- 將未訪問過的鏈接加入到 unvisited_urls_queue 隊尾
- 直到 unvisited_urls_queue 為空時終止任務
然后回到我們的問題,要限制訪問頻率,即單位時間內請求的鏈接數目。顯然,worker 之間相互獨立,要在執行端層面協同實現整體的頻率控制并不容易。但從上面的步驟中可以看出,unvisited_urls_queue 被所有 worker 共享,并且作為源頭供給的角色。那么只要我們可以實現對 unvisited_urls_queue 補充的數量控制,就實現了爬蟲整體的訪問頻率控制。
以上思路是正確的,但在具體實現的時候會存在幾個問題:
- 需要一個用于存儲已經訪問鏈接的集合(visited_urls_set),該集合需要在各個 worker 中實現共享;
- 需要一個全局的計數器,統計到達設定時間間隔(rps即1秒,rpm即1分鐘)時已訪問的總鏈接數;
并且在當前的實際場景中,最佳的并發機制是選擇多進程(下文會詳細說明原因),每個 worker 在不同的進程中,那要實現對集合的共享就不大容易了。同時,如果每個 worker 都要負責對總請求數進行判斷,即將訪問頻率的控制邏輯放到 worker 中實現,那對于 worker 來說會是一個負擔,邏輯上也會比較復雜。
因此比較好的方式是,除了未訪問鏈接隊列(unvisited_urls_queue),另外再新增一個爬取結果的存儲隊列(fetched_urls_queue),這兩個隊列都在各個 worker 中共享。那么,接下來邏輯就變得簡單了:
- 在各個 worker 中,只需要從 unvisited_urls_queue 中取數據,解析出結果后統統存儲到 fetched_urls_queue,無需關注訪問頻率的問題;
- 在主進程中,不斷地從 fetched_urls_queue 取數據,將未訪問過的鏈接添加到 unvisited_urls_queue,在添加之前進行訪問頻率控制。
具體的控制方法也很簡單,假設我們是要實現 RPS 的控制,那么就可以使用如下方式(只截取關鍵片段):
start_timer = time.time()
requests_queued = 0
while True:
try:
url = self.fetched_urls_queue.get(timeout=5)
except queue.Empty:
break
# visited url will not be crawled twice
if url in self.visited_urls_set:
continue
# limit rps or rpm
if requests_queued >= self.requests_limit:
runtime_secs = time.time() - start_timer
if runtime_secs < self.interval_limit:
sleep_secs = self.interval_limit - runtime_secs
# exceed rps limit, sleep
time.sleep(sleep_secs)
start_timer = time.time()
requests_queued = 0
self.unvisited_urls_queue.put(url)
self.visited_urls_set.add(url)
requests_queued += 1
提升爬蟲效率
對于提升爬蟲效率這部分,當前已經有大量的討論了,重點都是集中在不同的并發機制上面,包括多進程、多線程、asyncio等。
不過,他們的并發測試結果對于本文中討論的爬蟲場景并不適用。因為在本文的爬蟲場景中,實現前端頁面渲染是最核心的一項功能特性,而要實現前端頁面渲染,底層都是需要使用瀏覽器內核的,相當于每個 worker 在運行時都會跑一個 Chromium 實例。
眾所周知,Chromium 對于 CPU 和內存的開銷都是比較大的,因此為了避免機器資源出現瓶頸,使用多進程機制(multiprocessing)充分調用多處理器的硬件資源無疑是最佳的選擇。
另一個需要注意也是比較被大家忽略的點,就是在頁面鏈接的請求方法上。
請求頁面鏈接,不都是使用 GET 方法么?
的確,使用 GET 請求肯定是可行的,但問題在于,GET 請求時會加載頁面中的所有資源信息,這本身會是比較耗時的,特別是遇到鏈接為比較大的圖片或者附件的時候。這無疑會耗費很多無謂的時間,畢竟我們的目的只是為了檢測鏈接資源是否可訪問而已。
比較好的的做法是對網站的鏈接進行分類:
- 資源型鏈接,包括圖片、CSS、JS、文件、視頻、附件等,這類鏈接只需檢測可訪問性;
- 外站鏈接,這類鏈接只需檢測該鏈接本身的可訪問性,無需進一步檢測該鏈接加載后頁面中包含的鏈接;
- 本站頁面鏈接,這類鏈接除了需要檢測該鏈接本身的可訪問性,還需要進一步檢測該鏈接加載后頁面中包含的鏈接的可訪問性;
在如上分類中,除了第三類是必須要使用 GET 方法獲取頁面并加載完整內容(render),前兩類完全可以使用 HEAD 方法進行代替。一方面,HEAD 方法只會獲取狀態碼和 headers 而不獲取 body,比 GET 方法高效很多;另一方面,前兩類鏈接也無需進行頁面渲染,省去了調用 Chromium 進行解析的步驟,執行效率的提高也會非常明顯。
總結
本文針對如何使用爬蟲技術實現 Web 頁面資源可用性檢測進行了講解,重點圍繞爬蟲如何實現 全 、 穩 、 快 三個核心特性進行了展開。對于爬蟲技術的更多內容,后續有機會我們再進一步進行探討。
來自:http://debugtalk.com/post/requests-crawler/