設計和實(shí)現一款輕量級的爬蟲(chóng)框架
優(yōu)采云 發(fā)布時(shí)間: 2020-05-05 08:05
說(shuō)起爬蟲(chóng),大家能否想起 Python 里赫赫有名的 Scrapy 框架, 在本文中我們參考這個(gè)設計思想使用 Java 語(yǔ)言來(lái)實(shí)現一款自己的爬蟲(chóng)框(lun)架(zi)。 我們從起點(diǎn)一步一步剖析爬蟲(chóng)框架的誕生過(guò)程。
我把這個(gè)爬蟲(chóng)框架的源碼置于 github上,里面有幾個(gè)事例可以運行。
關(guān)于爬蟲(chóng)的一切
下面我們來(lái)介紹哪些是爬蟲(chóng)?以及爬蟲(chóng)框架的設計和碰到的問(wèn)題。
什么是爬蟲(chóng)?
“爬蟲(chóng)”不是一只生活在泥土里的小蟲(chóng)子,網(wǎng)絡(luò )爬蟲(chóng)(web crawler),也叫網(wǎng)路蜘蛛(spider),是一種拿來(lái)手動(dòng)瀏覽網(wǎng)路上內容的機器人。 爬蟲(chóng)訪(fǎng)問(wèn)網(wǎng)站的過(guò)程會(huì )消耗目標系統資源,很多網(wǎng)站不容許被爬蟲(chóng)抓?。ㄟ@就是你遇見(jiàn)過(guò)的 robots.txt 文件, 這個(gè)文件可以要求機器人只對網(wǎng)站的一部分進(jìn)行索引,或完全不作處理)。 因此在訪(fǎng)問(wèn)大量頁(yè)面時(shí),爬蟲(chóng)須要考慮到規劃、負載,還須要講“禮貌”(大兄弟,慢點(diǎn))。
互聯(lián)網(wǎng)上的頁(yè)面極多,即使是最大的爬蟲(chóng)系統也未能作出完整的索引。因此在公元2000年之前的萬(wàn)維網(wǎng)出現早期,搜索引擎常常找不到多少相關(guān)結果。 現在的搜索引擎在這方面早已進(jìn)步好多,能夠即刻給出高質(zhì)量結果。
網(wǎng)絡(luò )爬蟲(chóng)會(huì )碰到的問(wèn)題
既然有人想抓取,就會(huì )有人想防御。網(wǎng)絡(luò )爬蟲(chóng)在運行的過(guò)程中會(huì )碰到一些阻撓,在業(yè)內稱(chēng)之為 反爬蟲(chóng)策略 我們來(lái)列舉一些常見(jiàn)的。
這些是傳統的反爬蟲(chóng)手段,當然未來(lái)也會(huì )愈加先進(jìn),技術(shù)的革新永遠會(huì )推動(dòng)多個(gè)行業(yè)的發(fā)展,畢竟 AI 的時(shí)代早已到來(lái), 爬蟲(chóng)和反爬蟲(chóng)的斗爭仍然持續進(jìn)行。
爬蟲(chóng)框架要考慮哪些
設計我們的框架
我們要設計一款爬蟲(chóng)框架,是基于 Scrapy 的設計思路來(lái)完成的,先來(lái)瞧瞧在沒(méi)有爬蟲(chóng)框架的時(shí)侯我們是怎樣抓取頁(yè)面信息的。 一個(gè)常見(jiàn)的事例是使用 HttpClient 包或則 Jsoup 來(lái)處理,對于一個(gè)簡(jiǎn)單的小爬蟲(chóng)而言這足夠了。
下面來(lái)演示一段沒(méi)有爬蟲(chóng)框架的時(shí)侯抓取頁(yè)面的代碼,這是我在網(wǎng)路上搜索的
public class Reptile {
public static void main(String[] args) {
//傳入你所要爬取的頁(yè)面地址
String url1 = "";
//創(chuàng )建輸入流用于讀取流
InputStream is = null;
//包裝流,加快讀取速度
BufferedReader br = null;
//用來(lái)保存讀取頁(yè)面的數據.
StringBuffer html = new StringBuffer();
//創(chuàng )建臨時(shí)字符串用于保存每一次讀的一行數據,然后html調用append方法寫(xiě)入temp;
String temp = "";
try {
//獲取URL;
URL url2 = new URL(url1);
//打開(kāi)流,準備開(kāi)始讀取數據;
is = url2.openStream();
//將流包裝成字符流,調用br.readLine()可以提高讀取效率,每次讀取一行;
br= new BufferedReader(new InputStreamReader(is));
//讀取數據,調用br.readLine()方法每次讀取一行數據,并賦值給temp,如果沒(méi)數據則值==null,跳出循環(huán);
while ((temp = br.readLine()) != null) {
//將temp的值追加給html,這里注意的時(shí)String跟StringBuffere的區別前者不是可變的后者是可變的;
html.append(temp);
}
//接下來(lái)是關(guān)閉流,防止資源的浪費;
if(is != null) {
is.close();
is = null;
}
//通過(guò)Jsoup解析頁(yè)面,生成一個(gè)document對象;
Document doc = Jsoup.parse(html.toString());
//通過(guò)class的名字得到(即XX),一個(gè)數組對象Elements里面有我們想要的數據,至于這個(gè)div的值呢你打開(kāi)瀏覽器按下F12就知道了;
Elements elements = doc.getElementsByClass("XX");
for (Element element : elements) {
//打印出每一個(gè)節點(diǎn)的信息;你可以選擇性的保留你想要的數據,一般都是獲取個(gè)固定的索引;
System.out.println(element.text());
}
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
從這么豐富的注釋中我感受到了作者的耐心,我們來(lái)剖析一下這個(gè)爬蟲(chóng)在干哪些?
大概就是這樣的步驟,代碼也十分簡(jiǎn)約,我們設計框架的目的是將這種流程統一化,把通用的功能進(jìn)行具象,減少重復工作。 還有一些沒(méi)考慮到的誘因添加進(jìn)去爬蟲(chóng)框架,那么設計爬蟲(chóng)框架要有什么組成呢?
分別來(lái)解釋一下每位組成的作用是哪些。
URL管理器
爬蟲(chóng)框架要處理好多的URL,我們須要設計一個(gè)隊列儲存所有要處理的URL,這種先進(jìn)先出的數據結構十分符合這個(gè)需求。 將所有要下載的URL存貯在待處理隊列中,每次下載會(huì )取出一個(gè),隊列中還會(huì )少一個(gè)。我們曉得有些URL的下載會(huì )有反爬蟲(chóng)策略, 所以針對那些懇求須要做一些特殊的設置,進(jìn)而可以對URL進(jìn)行封裝抽出 Request。
網(wǎng)頁(yè)下載器
在上面的簡(jiǎn)單事例中可以看出,如果沒(méi)有網(wǎng)頁(yè)下載器,用戶(hù)就要編撰網(wǎng)路懇求的處理代碼,這無(wú)疑對每位URL都是相同的動(dòng)作。 所以在框架設計中我們直接加入它就好了,至于使用哪些庫來(lái)進(jìn)行下載都是可以的,你可以用 httpclient 也可以用 okhttp, 在本文中我們使用一個(gè)超輕量級的網(wǎng)路懇求庫 oh-my-request (沒(méi)錯,就是在下搞的)。 優(yōu)秀的框架設計會(huì )將這個(gè)下載組件置為可替換,提供默認的即可。
爬蟲(chóng)調度器
調度器和我們在開(kāi)發(fā) web 應用中的控制器是一個(gè)類(lèi)似的概念,它用于在下載器、解析器之間做流轉處理。 解析器可以解析到更多的URL發(fā)送給調度器,調度器再度的傳輸給下載器,這樣才會(huì )使各個(gè)組件有條不紊的進(jìn)行工作。
網(wǎng)頁(yè)解析器
我們曉得當一個(gè)頁(yè)面下載完成后就是一段 HTML 的 DOM 字符串表示,但還須要提取出真正須要的數據, 以前的做法是通過(guò) String 的 API 或者正則表達式的形式在 DOM 中搜救,這樣是很麻煩的,框架 應該提供一種合理、常用、方便的方法來(lái)幫助用戶(hù)完成提取數據這件事兒。常用的手段是通過(guò) xpath 或者 css 選擇器從 DOM 中進(jìn)行提取,而且學(xué)習這項技能在幾乎所有的爬蟲(chóng)框架中都是適用的。
數據處理器
普通的爬蟲(chóng)程序中是把 網(wǎng)頁(yè)解析器 和 數據處理器 合在一起的,解析到數據后馬上處理。 在一個(gè)標準化的爬蟲(chóng)程序中,他們應當是各司其職的,我們先通過(guò)解析器將須要的數據解析下來(lái),可能是封裝成對象。 然后傳遞給數據處理器,處理器接收到數據后可能是儲存到數據庫,也可能通過(guò)插口發(fā)送給老王。
基本特點(diǎn)
上面說(shuō)了這么多,我們設計的爬蟲(chóng)框架有以下幾個(gè)特點(diǎn),沒(méi)有做到大而全,可以稱(chēng)得上輕量迷你很好用。
架構圖
整個(gè)流程和 Scrapy 是一致的,但簡(jiǎn)化了一些操作
執行流程圖
項目結構
該項目使用 Maven3、Java8 進(jìn)行完善,代碼結構如下:
.
└── elves
├── Elves.java
├── ElvesEngine.java
├── config
├── download
├── event
├── pipeline
├── request
├── response
├── scheduler
├── spider
└── utils
編碼要點(diǎn)
前面設計思路明白以后,編程不過(guò)是順手之作,至于寫(xiě)的怎么審視的是程序員對編程語(yǔ)言的使用熟練度以及構架上的思索, 優(yōu)秀的代碼是經(jīng)驗和優(yōu)化而至的,下面我們來(lái)看幾個(gè)框架中的代碼示例。
使用觀(guān)察者模式的思想來(lái)實(shí)現基于風(fēng)波驅動(dòng)的功能
public enum ElvesEvent {
GLOBAL_STARTED,
SPIDER_STARTED
}
public class EventManager {
private static final Map<ElvesEvent, List<Consumer<Config>>> elvesEventConsumerMap = new HashMap<>();
// 注冊事件
public static void registerEvent(ElvesEvent elvesEvent, Consumer<Config> consumer) {
List<Consumer<Config>> consumers = elvesEventConsumerMap.get(elvesEvent);
if (null == consumers) {
consumers = new ArrayList<>();
}
consumers.add(consumer);
elvesEventConsumerMap.put(elvesEvent, consumers);
}
// 執行事件
public static void fireEvent(ElvesEvent elvesEvent, Config config) {
Optional.ofNullable(elvesEventConsumerMap.get(elvesEvent)).ifPresent(consumers -> consumers.forEach(consumer -> consumer.accept(config)));
}
}
這段代碼中使用一個(gè) Map 來(lái)儲存所有風(fēng)波,提供兩個(gè)方式:注冊一個(gè)風(fēng)波、執行某個(gè)風(fēng)波。
阻塞隊列儲存懇求響應
public class Scheduler {
private BlockingQueue<Request> pending = new LinkedBlockingQueue<>();
private BlockingQueue<Response> result = new LinkedBlockingQueue<>();
public void addRequest(Request request) {
try {
this.pending.put(request);
} catch (InterruptedException e) {
log.error("向調度器添加 Request 出錯", e);
}
}
public void addResponse(Response response) {
try {
this.result.put(response);
} catch (InterruptedException e) {
log.error("向調度器添加 Response 出錯", e);
}
}
public boolean hasRequest() {
return pending.size() > 0;
}
public Request nextRequest() {
try {
return pending.take();
} catch (InterruptedException e) {
log.error("從調度器獲取 Request 出錯", e);
return null;
}
}
public boolean hasResponse() {
return result.size() > 0;
}
public Response nextResponse() {
try {
return result.take();
} catch (InterruptedException e) {
log.error("從調度器獲取 Response 出錯", e);
return null;
}
}
public void addRequests(List<Request> requests) {
requests.forEach(this::addRequest);
}
}
pending 存儲等待處理的URL懇求,result 存儲下載成功的響應,調度器負責懇求和響應的獲取和添加流轉。
舉個(gè)栗子
設計好我們的爬蟲(chóng)框架后來(lái)試一下吧,這個(gè)事例我們來(lái)爬取豆瓣影片的標題。豆瓣影片中有很多分類(lèi),我們可以選擇幾個(gè)作為開(kāi)始抓取的 URL。
public class DoubanSpider extends Spider {
public DoubanSpider(String name) {
super(name);
}
@Override
public void onStart(Config config) {
this.startUrls(
"https://movie.douban.com/tag/愛(ài)情",
"https://movie.douban.com/tag/喜劇",
"https://movie.douban.com/tag/*敏*感*詞*",
"https://movie.douban.com/tag/動(dòng)作",
"https://movie.douban.com/tag/史詩(shī)",
"https://movie.douban.com/tag/*敏*感*詞*");
this.addPipeline((Pipeline<List<String>>) (item, request) -> log.info("保存到文件: {}", item));
}
public Result parse(Response response) {
Result<List<String>> result = new Result<>();
Elements elements = response.body().css("#content table .pl2 a");
List<String> titles = elements.stream().map(Element::text).collect(Collectors.toList());
result.setItem(titles);
// 獲取下一頁(yè) URL
Elements nextEl = response.body().css("#content > div > div.article > div.paginator > span.next > a");
if (null != nextEl && nextEl.size() > 0) {
String nextPageUrl = nextEl.get(0).attr("href");
Request nextReq = this.makeRequest(nextPageUrl, this::parse);
result.addRequest(nextReq);
}
return result;
}
}
public static void main(String[] args) {
DoubanSpider doubanSpider = new DoubanSpider("豆瓣電影");
Elves.me(doubanSpider, Config.me()).start();
}
這段代碼中在 onStart 方法是爬蟲(chóng)啟動(dòng)時(shí)的一個(gè)風(fēng)波,會(huì )在啟動(dòng)該爬蟲(chóng)的時(shí)侯執行,在這里我們設置了啟動(dòng)要抓取的URL列表。 然后添加了一個(gè)數據處理的 Pipeline,在這里處理管線(xiàn)中只進(jìn)行了輸出,你也可以?xún)Υ妗?/p>
在 parse 方法中做了兩件事,首先解析當前抓取到的所有影片標題,將標題數據搜集為 List 傳遞給 Pipeline; 其次按照當前頁(yè)面繼續抓取下一頁(yè),將下一頁(yè)懇求傳遞給調度器爬蟲(chóng)框架,由調度器轉發(fā)給下載器。這里我們使用一個(gè) Result 對象接收。
總結
設計一款爬蟲(chóng)框架的基本要點(diǎn)在文中早已論述,要做的更好還有好多細節須要打磨,比如分布式、容錯恢復、動(dòng)態(tài)頁(yè)面抓取等問(wèn)題。 歡迎在 elves 中遞交你的意見(jiàn)。
參考文獻






