# 第七章 到達哲學
> 原文:[Chapter 7 Getting to Philosophy](http://greenteapress.com/thinkdast/html/thinkdast008.html)
> 譯者:[飛龍](https://github.com/wizardforcel)
> 協議:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/)
> 自豪地采用[谷歌翻譯](https://translate.google.cn/)
本章的目標是開發一個 Web 爬蟲,它測試了第 6.1 節中提到的“到達哲學”猜想。
## 7.1 起步
在本書的倉庫中,你將找到一些幫助你起步的代碼:
+ `WikiNodeExample.java`包含前一章的代碼,展示了 DOM 樹中深度優先搜索(DFS)的遞歸和迭代實現。
+ `WikiNodeIterable.java`包含`Iterable`類,用于遍歷 DOM 樹。我將在下一節中解釋這段代碼。
+ `WikiFetcher.java`包含一個工具類,使用`jsoup`從維基百科下載頁面。為了幫助你遵守維基百科的服務條款,此類限制了你下載頁面的速度;如果你每秒請求許多頁,在下載下一頁之前會休眠一段時間。
+ `WikiPhilosophy.java`包含你為此練習編寫的代碼的大綱。我們將在下面進行說明。
你還會發現 Ant 構建文件`build.xml`。如果你運行`ant WikiPhilosophy`,它將運行一些簡單的啟動代碼。
## 7.2 可迭代對象和迭代器
在前一章中,我展示了迭代式深度優先搜索(DFS),并且認為與遞歸版本相比,迭代版本的優點在于,它更容易包裝在`Iterator`對象中。在本節中,我們將看到如何實現它。
如果你不熟悉`Iterator`和`Iterable`接口,你可以閱讀 <http://thinkdast.com/iterator> 和 <http://thinkdast.com/iterable>。
看看`WikiNodeIterable.java`的內容。外層的類`WikiNodeIterable`實現`Iterable<Node>`接口,所以我們可以在一個`for`循環中使用它:
```java
Node root = ...
Iterable<Node> iter = new WikiNodeIterable(root);
for (Node node: iter) {
visit(node);
}
```
其中`root`是我們想要遍歷的樹的根節點,并且`visit`是一個方法,當我們“訪問”`Node`時,做任何我們想要的事情。
`WikiNodeIterable`的實現遵循以下慣例:
+ 構造函數接受并存儲根`Node`的引用。
+ `iterator`方法創建一個返回一個`Iterator`對象。
這是它的樣子:
```java
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`,執行所有實際工作。
```java
private class WikiNodeIterator implements Iterator<Node> {
Deque<Node> stack;
public WikiNodeIterator(Node node) {
stack = new ArrayDeque<Node>();
stack.push(root);
}
@Override
public boolean hasNext() {
return !stack.isEmpty();
}
@Override
public Node next() {
if (stack.isEmpty()) {
throw new NoSuchElementException();
}
Node node = stack.pop();
List<Node> nodes = new ArrayList<Node>(node.childNodes());
Collections.reverse(nodes);
for (Node child: nodes) {
stack.push(child);
}
return node;
}
}
```
該代碼與 DFS 的迭代版本幾乎相同,但現在分為三個方法:
+ 構造函數初始化棧(使用一個`ArrayDeque`實現)并將根節點壓入這個棧。
+ `isEmpty`檢查棧是否為空。
+ `next`從`Node`棧中彈出下一個節點,按相反的順序壓入子節點,并返回彈出的`Node`。如果有人在空`Iterator`上調用`next`,則會拋出異常。
可能不明顯的是,值得使用兩個類和五個方法,來重寫一個完美的方法。但是現在我們已經完成了,在需要`Iterable`的任何地方,我們可以使用`WikiNodeIterable`,這使得它的語法整潔,易于將迭代邏輯(DFS)與我們對節點的處理分開。
## 7.3 `WikiFetcher`
編寫 Web 爬蟲時,很容易下載太多頁面,這可能會違反你要下載的服務器的服務條款。為了幫助你避免這種情況,我提供了一個`WikiFetcher`類,它可以做兩件事情:
+ 它封裝了我們在上一章中介紹的代碼,用于從維基百科下載頁面,解析 HTML 以及選擇內容文本。
+ 它測量請求之間的時間,如果我們在請求之間沒有足夠的時間,它將休眠直到經過了合理的間隔。默認情況下,間隔為`1`秒。
這里是`WikiFetcher`的定義:
```java
public class WikiFetcher {
private long lastRequestTime = -1;
private long minInterval = 1000;
/**
* Fetches and parses a URL string,
* returning a list of paragraph elements.
*
* @param url
* @return
* @throws IOException
*/
public Elements fetchWikipedia(String url) throws IOException {
sleepIfNeeded();
Connection conn = Jsoup.connect(url);
Document doc = conn.get();
Element content = doc.getElementById("mw-content-text");
Elements paragraphs = content.select("p");
return paragraphs;
}
private void sleepIfNeeded() {
if (lastRequestTime != -1) {
long currentTime = System.currentTimeMillis();
long nextRequestTime = lastRequestTime + minInterval;
if (currentTime < nextRequestTime) {
try {
Thread.sleep(nextRequestTime - currentTime);
} catch (InterruptedException e) {
System.err.println(
"Warning: sleep interrupted in fetchWikipedia.");
}
}
}
lastRequestTime = System.currentTimeMillis();
}
}
```
唯一的公共方法是`fetchWikipedia`,接收`String`形式的 URL,并返回一個`Elements`集合,該集合包含的一個 DOM 元素表示內容文本中每個段落。這段代碼應該很熟悉了。
新的代碼是`sleepIfNeeded`,它檢查自上次請求以來的時間,如果經過的時間小于`minInterval`(毫秒),則休眠。
這就是`WikiFetcher`全部。這是一個演示如何使用它的例子:
```java
WikiFetcher wf = new WikiFetcher();
for (String url: urlList) {
Elements paragraphs = wf.fetchWikipedia(url);
processParagraphs(paragraphs);
}
```
在這個例子中,我們假設`urlList`是一個`String`的集合 ,并且`processParagraphs`是一個方法,對`Elements`做一些事情,它由`fetchWikipedia`返回。
此示例展示了一些重要的東西:你應該創建一個`WikiFetcher`對象并使用它來處理所有請求。如果有多個`WikiFetcher`的實例,則它們不會確保請求之間的最小間隔。
注意:我的`WikiFetcher`實現很簡單,但是通過創建多個實例,人們很容易誤用它。你可以通過制作`WikiFetcher`“單例” 來避免這個問題,你可以閱讀 <http://thinkdast.com/singleton>。
## 7.4 練習 5
在`WikiPhilosophy.java`中,你會發現一個簡單的`main`方法,展示了如何使用這些部分。從這個代碼開始,你的工作是寫一個爬蟲:
1. 獲取維基百科頁面的 URL,下載并分析。
1. 它應該遍歷所得到的 DOM 樹來找到第一個 有效的鏈接。我會在下面解釋“有效”的含義。
1. 如果頁面沒有鏈接,或者如果第一個鏈接是我們已經看到的頁面,程序應該指示失敗并退出。
1. 如果鏈接匹配維基百科頁面上的哲學網址,程序應該提示成功并退出。
1. 否則應該回到步驟`1`。
該程序應該為它訪問的 URL 構建`List`,并在結束時顯示結果(無論成功還是失敗)。
那么我們應該認為什么是“有效的”鏈接?你在這里有一些選擇 各種版本的“到達哲學”推測使用略有不同的規則,但這里有一些選擇:
+ 這個鏈接應該在頁面的內容文本中,而不是側欄或彈出框。
+ 它不應該是斜體或括號。
+ 你應該跳過外部鏈接,當前頁面的鏈接和紅色鏈接。
+ 在某些版本中,如果文本以大寫字母開頭,則應跳過鏈接。
你不必遵循所有這些規則,但我們建議你至少處理括號,斜體以及當前頁面的鏈接。
如果你有足夠的信息來起步,請繼續。或者你可能想要閱讀這些提示:
+ 當你遍歷樹的時候,你將需要處理的兩種`Node`是`TextNode`和`Element`。如果你找到一個`Element`,你可能需要轉換它的類型,來訪問標簽和其他信息。
+ 當你找到包含鏈接的`Element`時,通過向上跟蹤父節點鏈,可以檢查是否是斜體。如果父節點鏈中有一個`<i>`或`<em>`標簽,鏈接為斜體。
+ 為了檢查鏈接是否在括號中,你必須在遍歷樹時掃描文本,并跟蹤開啟和閉合括號(理想情況下,你的解決方案應該能夠處理嵌套括號(像這樣))。
如果你從 Java 頁面開始,你應該在跟隨七個鏈接之后到達哲學,除非我運行代碼后發生了改變。
好的,這就是你所得到的所有幫助。現在全靠你了。玩的開心!