爬蟲究竟是怎樣開始的?
爬蟲究竟是怎樣開始的?這個問題是一個很難的哲學問題。如果看官對技術術語更敏感,那么可以表述為,爬蟲,嚴格說爬蟲的scheduler,如果需要保持一個status,到底是poll還是push?
本文將討論并解決這個問題。(注:不說人話)
從“一次性”爬蟲開始說起
上圖為最簡單的一次爬蟲過程。究其本質,這個過程的核心是一個"種子頁面"->"目標頁面"->"目標頁面鏈接"->"目標頁面"的循環。也就是說,給定若干種子頁面,和一些目標頁面鏈接的匹配規則Rule,一定可以通過不斷循環,把這個網站Site上所有被覆蓋到的、符合匹配規則的頁面集合P={page ∈ Site|Rule}獲取到。這個P是一個有窮集合。P獲取到了,爬蟲結束。
討論到這,大概勉強達到大學計算機專業本科期末作業的水平。但是想要及格,或許還應該考慮這么幾個方面:
- 數據庫:這個話題能截好多圖:)
- 多線程:P的數量如果很大,我需要把“下載網頁、保存數據庫”這樣的動作放進多線程里跑跑。
- 前端隊列:我需要一個前端隊列!“生產者-消費者”模式,呵呵。
- 歷史隊列:是的,我還需要一個歷史隊列!不然爬重復了怎么辦?
- Javascript:淘寶的網頁怎么爬?
- 反爬:這是一個long story,甚至比人類的歷史還要久遠。
Done。至此我們已經實現了一個異常牛逼的爬蟲程序。如果想把“程序”變成“系統”,則需要考慮更多工程上的東西:如何分布式。
穩定性遷移
分布式的終極目的,是要把單機程序(進程)里任何不穩定因素,通過遷移到外部更穩定的程序(進程)的方式,達到系統全局更穩定的目的。另外一方面,通過這幾年被“微服務”概念不斷地洗腦,工程人員最喜歡用的一個詞是“解耦合”。
更進一步,如果我們希望我們的爬蟲是一個“永遠在線”的服務(引擎),“爬某一個網站的網頁”這樣的事情被當成某一個任務(task),隨時啟動、暫停、停止,這不得不讓我們重新在架構上考慮得更多。
document-service
之前討論的幾個方面中,最應該第一個抽取出來的服務就是存儲服務。無論選擇使用MongoDB還是ES來保存網頁,對外屏蔽底層的存儲方案無疑是個優雅的idea。對外透出save()和saveBatch()操作。
queue-service
抽取出“前端隊列”和“歷史隊列”作為隊列服務。如果你將Redis作為選型方案,那么“前端隊列”是一個List,支持類似fetch(30)這樣的操作;“歷史隊列”則是一個Set,支持hasVisit()和visit(link)這樣的操作。
downloader-service
作為高階玩家,抽離出網頁下載服務也是有必要的。URL進,Document出。downloader對外屏蔽了諸如“異常重試”、“中文編碼自適應”、“代理IP池維護和切換”、“Headless瀏覽器渲染”、“http連接池復用”等一切跟網絡IO有關的細節。
sql-service
如前所述,爬取的工作被當成任務來執行。每一項任務自身有著各種meta信息,啟動一個任務的示例(instance)也關聯運行時的meta信息,這些信息保存在關系數據庫中并對外透出CRUD接口,在此不做贅述。
回到正題:scheduler
前文所述的各種service作為工具一樣的組件封裝完備后,為了讓整個爬蟲run起來,接下來該討論整個爬蟲引擎中最核心的scheduler-worker問題了。
Master-Worker模式的分布式框架,從Doug Cutting寫下Hadoop的第一行代碼開始逐漸深入人心。很不幸的是我們并不能從中獲得太多啟發。
先說worker。由于爬蟲引擎是“永遠在線”的,那么worker(一個獨立的進程)也是永遠在線的。因此我們想到了push方案:
- worker-push方案。即,worker們在啟動時,把自身注冊到scheduler中。scheduler中維護了worker們的通訊ip列表,當有任務啟動時,scheduler在列表中隨機挑選worker,并在queue-service中fetch(N)條待爬取的URLs,然后post給worker去抓取。
好的,開始自我挑戰吧。push最大的一個毛病,就是scheduler需要與worker建立了直接的通信并時刻測試通信。不要試圖從Hadoop中找方案,憑直覺我們又想到了poll方案:
- worker-poll方案。即,worker保持一個while/true循環(sleeps may be),不斷的看看queue-service里有沒有要爬的網頁。沒有就continue,有就該干啥干啥。當然,worker們監聽一個消息隊列也是可取的,這樣queue-service就不得不做一些改造。
Worker以poll的方式監聽消息隊列自然是一個省事的好方式,但scheduler側該如何設計呢?先看push方案:
- scheduler-push方案。worker保持一個while/true循環(sleeps may be),不斷的看看種子列表(或隊列)里有沒有要爬的網頁。沒有就continue,有就該干啥干啥。
這樣做的好處是:首先容易實現,邏輯簡單明了,還可以動態調整sleep的時間,我靠太牛逼了!當然壞處是:不管怎么動態調整sleep的時間,始終是有滯后和開銷的矛盾。不太符合對實時性有要求的場景,說好的“事件驅動”呢?
屌,我們終于說到“事件驅動了”。我們現在要解決的問題是,如何感知到并且以最小的開銷讓任務啟動?那么下面提供一個push+pull的方案,也是本文作為一篇“記敘文”的中心思想,權當拋磚引玉:
- scheduler-push+pull方案。即,scheduler以較快頻率(30秒)poll種子列表的返回數據(網頁)是否有變動,如沒有變動continue,如有變動解析出任務URL,作為事件push到消息隊列中待worker爬取。
再接再厲,現在似乎剩下的最后一個問題就是,如何以最小開銷檢測種子列表里的網頁是否有變動呢?我們聯想到了瀏覽器+刷新的方式。基于開源框架selenium,甚至可以很容易實現對于“網頁局部元素是否有更新”這種監聽的動作。
show me the code
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import io.reactivex.schedulers.Schedulers;
import jodd.util.StringUtil;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.PageLoadStrategy;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import utils.MD5;
/**
* @author craig
* @since 2018年6月21日 上午10:50:54
*/
public class SiteMonitor {
private WebDriver driver;
private Map<SiteBean, Integer> siteBeanMap;
private Map<SiteBean, String> siteTokenMap;
/**
*
*/
public SiteMonitor(String chromePath) {
System.setProperty("webdriver.chrome.driver", chromePath);
ChromeOptions co = new ChromeOptions();
co.setPageLoadStrategy(PageLoadStrategy.NORMAL);
co.setHeadless(true);
driver = new ChromeDriver(co);
siteBeanMap = Maps.newHashMap();
siteTokenMap = Maps.newHashMap();
}
/**
* @throws InterruptedException
*
*/
public Document openInNewTab(WebDriver webDriver, SiteBean siteBean) throws InterruptedException {
List<String> tabs = Lists.newArrayList(driver.getWindowHandles());
((JavascriptExecutor) driver).executeScript("window.open('about:blank','_blank');");
tabs = Lists.newArrayList(driver.getWindowHandles());
siteBeanMap.put(siteBean, tabs.size() - 1);
siteTokenMap.put(siteBean, "");
driver.switchTo().window(tabs.get(tabs.size() - 1));
driver.navigate().to(siteBean.getSiteURL());
return Jsoup.parse(driver.getPageSource(), siteBean.getHost());
}
/**
* @throws InterruptedException
*
*/
public void monitoring(List<SiteBean> siteBeanList) throws InterruptedException {
for (int i = 0; i < siteBeanList.size(); i++) {
openInNewTab(driver, siteBeanList.get(i));
}
Schedulers.trampoline().createWorker().schedulePeriodically(new Runnable() {
@Override
public void run() {
for (int i = 0; i < siteBeanList.size(); i++) {
SiteBean sb = siteBeanList.get(i);
driver.switchTo().window(Lists.newArrayList(driver.getWindowHandles()).get(siteBeanMap.get(sb)));
WebElement newContent = new WebDriverWait(driver, 60)
.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(sb.getElementLocated())));
String newToken = MD5.getMD5(newContent.getText());
if (!StringUtil.equals(siteTokenMap.get(sb), newToken)) {
siteTokenMap.put(sb, newToken);
String html = newContent.getAttribute("outerHTML");
Document doc = Jsoup.parseBodyFragment(html);
System.out.println(sb.getSiteName() + "有更新:" + doc.text());
System.out.println("~~~~~~~~~~~~~~~~~~~~~~update!~~~~~~~~~~~~~~~~~~`");
} else {
System.out.println(sb.getSiteName() + "無更新");
}
}
}
}, 0, 1, TimeUnit.MINUTES);
}
public static void main(String[] args) throws Exception {
SiteMonitor sm = new SiteMonitor("/Users/craig/chromedriver");
//
List<SiteBean> siteBeanList = Lists.newArrayList();
SiteBean sb = new SiteBean();
sb.setSiteName("財經");
sb.setElementLocated("#instantPanel");
sb.setHost("163.com");
sb.setSiteURL("http://money.163.com/latest/");
siteBeanList.add(sb);
SiteBean sb2 = new SiteBean();
sb2.setSiteName("體育");
sb2.setElementLocated("#instantPanel");
sb2.setHost("163.com");
sb2.setSiteURL("http://sports.163.com/latest");
siteBeanList.add(sb2);
sm.monitoring(siteBeanList);
}
}
如你所見,通過以上代碼我們做到了近實時監聽種子頁面的變化,從而做到了讓爬蟲引擎永遠在線,通過消息隊列的方式解耦合了scheduler和worker,從而讓爬蟲朝穩定性上又邁進了一步。所以這也是本文作為一篇雜文的一個推論。也所以作為一篇雜文如果連推論都寫了出來,可想而知這是什么屌雜文。周末了不如回家吃飯好了^^。