本章的目標是開發一個Web爬蟲,同時驗證之前提到的
到達哲學
1.起步
首先介紹本章中幫你起步的代碼:
WikiNodeExample.java
包含前一章的代碼,展示了 DOM 樹中深度優先搜索(DFS)的遞歸和迭代實現。WikiNodeIterable.java
包含Iterable
類,用於遍歷 DOM 樹。我將在下一節中解釋這段代碼。WikiFetcher.java
包含一個工具類,使用jsoup
從維基百科下載頁面。爲了幫助你遵守維基百科的服務條款,此類限制了你下載頁面的速度;如果你每秒請求許多頁,在下載下一頁之前會休眠一段時間。WikiPhilosophy.java
包含你爲此練習編寫的代碼的大綱。我們將在下面進行說明。
2.可迭代對象和迭代器
在前一章中,我展示了迭代式深度優先搜索(DFS),並且認爲與遞歸版本相比, 迭代版本的優點在於, 它更容易包裝在Iterator
對象中(Iterable是一種數據結構,Iterator是用來迭代的迭代器)。在本節中,我們將看到如何實現它。
外層的類WikiNodeIterable
實現Iterable<Node>
接口,所以我們可以在一個for
循環中使用它:
Node root = ...
Iterable<Node> iter = new WikiNodeIterable(root);
for (Node node: iter) {
visit(node);
}
其中 root爲樹的根節點,visit() 是到Node節點時,你想做的任意的事.
WikiNodeIterable
的實現遵循以下慣例:
- 構造函數接受並存儲根
Node
的引用。 iterator
方法創建一個返回一個Iterator
對象。
下邊是它的樣子
public class WikiNodeIterable implements Iterable<Node> {
private Node root;
// 從給定的節點開始創造一個迭代器
public WikiNodeIterable(Node root) {
this.root = root;
}
@Override
public Iterator<Node> iterator() {
return new WikiNodeIterator(root);
}
}
內部類 WikiNodeIterator
,執行所有實際工作
//實現Iterator的內部類
private class WikiNodeIterator implements Iterator<Node> {
// 這個堆棧跟蹤等待訪問的節點
Deque<Node> stack;
// 使用堆棧上的根節點初始化Iterator。
public WikiNodeIterator(Node node) {
stack = new ArrayDeque<Node>();
stack.push(node);
}
@Override
public boolean hasNext() {
return !stack.isEmpty();
}
@Override
public Node next() {
if (stack.isEmpty()) {
throw new NoSuchElementException();
}
// 出棧
Node node = stack.pop();
// 反轉後將child節點放入堆棧中
List<Node> nodes = new ArrayList<Node>(node.childNodes());
Collections.reverse(nodes);
for (Node child : nodes) {
stack.push(child);
}
return node;
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
}
該代碼與 DFS 的迭代版本幾乎相同(DFS是寫在一塊的),但現在分爲三個方法:
- 構造函數初始化棧(使用一個
ArrayDeque
實現)並將根節點壓入這個棧。 isEmpty
檢查棧是否爲空。next
從Node
棧中彈出下一個節點,按相反的順序壓入子節點,並返回彈出的Node
。如果有人在空Iterator
上調用next
,則會拋出異常。
可能不明顯的是,值得使用兩個類和五個方法,來重寫一個完美的方法。但是現在我們已經完成了,在需要Iterable
的任何地方,我們可以使用WikiNodeIterable
,這使得它的語法整潔,易於將迭代邏輯(DFS)與我們對節點的處理分開。
3.WikiFetcher
編寫 Web 爬蟲時,很容易下載太多頁面,這可能會違反你要下載的服務器的服務條款。爲了幫助你避免這種情況,我提供了一個WikiFetcher
類,它可以做兩件事情:
- 它封裝了我們在上一章中介紹的代碼,用於從維基百科下載頁面,解析 HTML 以及選擇內容文本。
- 它測量請求之間的時間,如果我們在請求之間沒有足夠的時間,它將休眠直到經過了合理的間隔。默認情況下,間隔爲
1
秒(防止過度請求)。
這裏是WikiFetcher
的定義:
public class WikiFetcher {
private long lastRequestTime = -1;
private long minIterval = 1000;
/**
* @Author Ragty
* @Description 找到並解析數據
* @Date 10:15 2019/4/16
**/
public Elements fetchWikiPedia(String url) throws IOException {
sleepIfNeed();
Connection connection = Jsoup.connect(url);
Document doc = connection.get();
Element content = doc.getElementById("mw-content-text");
Elements para = content.select("p");
return para;
}
/**
* @Author Ragty
* @Description 通過最小訪問時間來限制範圍訪問頻率
* @Date 10:43 2019/4/16
**/
private void sleepIfNeed() {
if (lastRequestTime != -1) {
long currentTime = System.currentTimeMillis();
long nextRequestTime = lastRequestTime + minIterval;
if (currentTime < lastRequestTime) {
try {
Thread.sleep(nextRequestTime - currentTime);
} catch (InterruptedException e) {
System.err.println("Warning: sleep interrupted in fetchWikipedia.");
}
}
}
lastRequestTime = System.currentTimeMillis();
}
}
新的代碼是sleepIfNeeded
,它檢查自上次請求以來的時間,如果經過的時間小於minInterval
(毫秒),則休眠。
4.練習
在WikiPhilosophy.java
中,你會發現一個簡單的main
方法,展示瞭如何使用這些部分。從這個代碼開始,你的工作是寫一個爬蟲:
- 獲取維基百科頁面的 URL,下載並分析。
- 它應該遍歷所得到的 DOM 樹來找到第一個 有效的鏈接。我會在下面解釋“有效”的含義。
- 如果頁面沒有鏈接,或者如果第一個鏈接是我們已經看到的頁面,程序應該指示失敗並退出。
- 如果鏈接匹配維基百科頁面上的哲學網址,程序應該提示成功並退出。
- 否則應該回到步驟
1
。
該程序應該爲它訪問的 URL 構建List
,並在結束時顯示結果(無論成功還是失敗)。
那麼我們應該認爲什麼是“有效的”鏈接?
- 這個鏈接應該在頁面的內容文本中,而不是側欄或彈出框。
- 它不應該是斜體或括號。
- 你應該跳過外部鏈接,當前頁面的鏈接和紅色鏈接。
- 在某些版本中,如果文本以大寫字母開頭,則應跳過鏈接。
如果你有足夠的信息來起步,請繼續。或者你可能想要閱讀這些提示:
- 當你遍歷樹的時候,你將需要處理的兩種
Node
是TextNode
和Element
。如果你找到一個Element
,你可能需要轉換它的類型,來訪問標籤和其他信息。 - 當你找到包含鏈接的
Element
時,通過向上跟蹤父節點鏈,可以檢查是否是斜體。如果父節點鏈中有一個<i>
或<em>
標籤,鏈接爲斜體。 - 爲了檢查鏈接是否在括號中,你必須在遍歷樹時掃描文本,並跟蹤開啓和閉合括號(理想情況下,你的解決方案應該能夠處理嵌套括號(像這樣))。