文章摘要: 我們通常需要爬取的頁面連結會非常多還需要進一步檢測該連結載入後頁面中包含的連結的可訪問性
對於電商型別和內容服務型別的網站,經常會出現因為配置錯誤造成頁面連結無法訪問的情況(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 頁面資源可用性檢測進行了講解,重點圍繞爬蟲如何實現 全
、 穩
、 快
三個核心特性進行了展開。對於爬蟲技術的更多內容,後續有機會我們再進一步進行探討。