第一篇
準備寫個爬蟲, 可以怎麼搞?
使用場景
先定義一個最簡單的使用場景,給你一個url,把這個url中指定的内容爬下來,然後停止
- 一個待爬去的網址(有個地方指定爬的網址)
- 如何擷取指定的内容(可以配置規則來擷取指定的内容)
設計 & 實作
1. 基本資料結構
CrawlMeta.java
CrawlMeta.java
一個配置項,包含塞入的 url 和 擷取規則
/**
* Created by yihui on 2017/6/27.
*/@ToStringpublic class CrawlMeta { /**
* 待爬去的網址
*/
@Getter
@Setter
private String url; /**
* 擷取指定内容的規則, 因為一個網頁中,你可能擷取多個不同的内容, 是以放在集合中
*/
@Setter
private Set<String> selectorRules; // 這麼做的目的就是為了防止NPE, 也就是說支援不指定選擇規則
public Set<String> getSelectorRules() { return selectorRules != null ? selectorRules : new HashSet<>();
}
}
CrawlResult
CrawlResult
抓取的結果,除了根據比對的規則擷取的結果之外,把整個html的資料也儲存下來,這樣實際使用者就可以更靈活的重新定義擷取規則
import org.jsoup.nodes.Document;@Getter@Setter@ToStringpublic class CrawlResult { /**
* 爬取的網址
*/
private String url; /**
* 爬取的網址對應的 DOC 結構
*/
private Document htmlDoc; /**
* 選擇的結果,key為選擇規則,value為根據規則比對的結果
*/
private Map<String, List<String>> result;
}
說明:這裡采用jsoup來解析html
2. 爬取任務
爬取網頁的具體邏輯就放在這裡了
一個爬取的任務
CrawlJob
,爬蟲嘛,正常來講都會塞到一個線程中去執行,雖然我們是第一篇,也不至于low到直接放到主線程去做
面向接口程式設計,是以我們定義了一個
的接口
IJob
IJob.java
IJob.java
這裡定義了兩個方法,在job執行之前和之後的回調,加上主要某些邏輯可以放在這裡來做(如打日志,耗時統計等),将輔助的代碼從爬取的代碼中抽取,使代碼結構更整潔
public interface IJob extends Runnable { /**
* 在job執行之前回調的方法
*/
void beforeRun(); /**
* 在job執行完畢之後回調的方法
*/
void afterRun();
}
AbstractJob
AbstractJob
因為
IJob
多了兩個方法,是以就衍生了這個抽象類,不然每個具體的實作都得去實作這兩個方法,有點蛋疼
然後就是借用了一絲模闆設計模式的思路,把run方法也實作了,單獨拎了一個
doFetchPage
方法給子類來實作,具體的抓取網頁的邏輯
public abstract class AbstractJob implements IJob { public void beforeRun() {
} public void afterRun() {
} @Override
public void run() { this.beforeRun(); try { this.doFetchPage();
} catch (Exception e) {
e.printStackTrace();
} this.afterRun();
} /**
* 具體的抓去網頁的方法, 需要子類來補全實作邏輯
*
* @throws Exception
*/
public abstract void doFetchPage() throws Exception;
}
SimpleCrawlJob
SimpleCrawlJob
一個最簡單的實作類,直接利用了JDK的URL方法來抓去網頁,然後利用jsoup進行html結構解析,這個實作中有較多的寫死,先看着,下面就着手第一步優化
/**
* 最簡單的一個爬蟲任務
* <p>
* Created by yihui on 2017/6/27.
*/@Getter@Setterpublic class SimpleCrawlJob extends AbstractJob { /**
* 配置項資訊
*/
private CrawlMeta crawlMeta; /**
* 存儲爬取的結果
*/
private CrawlResult crawlResult; /**
* 執行抓取網頁
*/
public void doFetchPage() throws Exception {
URL url = new URL(crawlMeta.getUrl());
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
BufferedReader in = null;
StringBuilder result = new StringBuilder(); try { // 設定通用的請求屬性
connection.setRequestProperty("accept", "*/*");
connection.setRequestProperty("connection", "Keep-Alive");
connection.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)"); // 建立實際的連接配接
connection.connect();
Map<String, List<String>> map = connection.getHeaderFields(); //周遊所有的響應頭字段
for (String key : map.keySet()) {
System.out.println(key + "--->" + map.get(key));
} // 定義 BufferedReader輸入流來讀取URL的響應
in = new BufferedReader(new InputStreamReader(
connection.getInputStream()));
String line; while ((line = in.readLine()) != null) {
result.append(line);
}
} finally { // 使用finally塊來關閉輸入流
try { if (in != null) {
in.close();
}
} catch (Exception e2) {
e2.printStackTrace();
}
}
doParse(result.toString());
} private void doParse(String html) {
Document doc = Jsoup.parse(html);
Map<String, List<String>> map = new HashMap<>(crawlMeta.getSelectorRules().size()); for (String rule: crawlMeta.getSelectorRules()) {
List<String> list = new ArrayList<>(); for (Element element: doc.select(rule)) {
list.add(element.text());
}
map.put(rule, list);
} this.crawlResult = new CrawlResult(); this.crawlResult.setHtmlDoc(doc); this.crawlResult.setUrl(crawlMeta.getUrl()); this.crawlResult.setResult(map);
}
}
4. 測試
上面一個最簡單的爬蟲就完成了,就需要拉出來看看,是否可以正常的工作了
就拿自己的部落格作為測試網址,目标是擷取 title + content,是以測試代碼如下
/**
* 測試我們寫的最簡單的一個爬蟲,
*
* 目标是爬取一篇部落格
*/@Testpublic void testFetch() throws InterruptedException {
Set<String> selectRule = new HashSet<>();
selectRule.add("div[class=title]"); // 部落格标題
selectRule.add("div[class=blog-body]"); // 部落格正文
CrawlMeta crawlMeta = new CrawlMeta();
crawlMeta.setUrl(url); // 設定爬取的網址
crawlMeta.setSelectorRules(selectRule); // 設定抓去的内容
SimpleCrawlJob job = new SimpleCrawlJob();
job.setCrawlMeta(crawlMeta);
Thread thread = new Thread(job, "crawler-test");
thread.start();
thread.join(); // 確定線程執行完畢
CrawlResult result = job.getCrawlResult();
System.out.println(result);
}
代碼示範示意圖如下
從傳回的結果可以看出,抓取到的title中包含了部落格标題 + 作着,主要的解析是使用的 jsoup,是以這些抓去的規則可以參考jsoup的使用方式
優化
- 上面完成之後,有個地方看着就不太舒服,
方法中的抓去網頁,有不少的寫死,而且那麼一大串看着也不太利索, 是以考慮加一個配置項,用于記錄HTTP相關的參數
doFetchPage
- 可以用更成熟的http架構來取代jdk的通路方式,維護和使用更加簡單
僅針對這個最簡單的爬蟲,我們開始着手上面的兩個優化點
1. 改用 HttpClient 來執行網絡請求
使用httpClient,重新改上面的擷取網頁代碼(暫不考慮配置項的情況), 對比之後發現代碼會簡潔很多
/**
* 執行抓取網頁
*/public void doFetchPage() throws Exception {
HttpClient httpClient = HttpClients.createDefault();
HttpGet httpGet = new HttpGet(crawlMeta.getUrl());
HttpResponse response = httpClient.execute(httpGet);
String res = EntityUtils.toString(response.getEntity()); if (response.getStatusLine().getStatusCode() == 200) { // 請求成功
doParse(res);
} else { this.crawlResult = new CrawlResult(); this.crawlResult.setStatus(response.getStatusLine().getStatusCode(), response.getStatusLine().getReasonPhrase()); this.crawlResult.setUrl(crawlMeta.getUrl());
}
}
這裡加了一個對傳回的code進行判斷,相容了一把通路不到資料的情況,對應的傳回結果中,新加了一個表示狀态的對象
CrawlResult
private Status status;public void setStatus(int code, String msg) { this.status = new Status(code, msg);
}@Getter@Setter@ToString@AllArgsConstructorstatic class Status { private int code; private String msg;
}
然後再進行測試,結果發現傳回狀态為 403, 主要是沒有設定一些必要的請求參數,被攔截了,手動塞幾個參數再試則ok
HttpGet httpGet = new HttpGet(crawlMeta.getUrl());
httpGet.addHeader("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
httpGet.addHeader("connection", "Keep-Alive");
httpGet.addHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10121) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36");
2. http配置項
顯然每次都這麼手動塞入參數是不可選的,我們有必要透出一個接口,由使用者自己來指定一些請求和傳回參數
首先我們可以确認下都有些什麼樣的配置項
- 請求方法: GET, POST, OPTIONS, DELET …
- RequestHeader:
Accept
Cookie
Host
Referer
User-Agent
Accept-Encoding
… (直接打開一個網頁,看請求的hedaers即可)Accept-Language
- 請求參數
- ResponseHeader: 這個我們沒法設定,但是我們可以設定網頁的編碼(這個來fix中文亂碼比較使用)
- 是否走https(這個暫時可以不考慮,後面讨論)
新增一個配置檔案,配置參數主要為
- 請求方法
- 請求參數
- 請求頭
@ToStringpublic class CrawlHttpConf { private static Map<String, String> DEFAULT_HEADERS; static {
DEFAULT_HEADERS = new HashMap<>();
DEFAULT_HEADERS.put("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
DEFAULT_HEADERS.put("connection", "Keep-Alive");
DEFAULT_HEADERS.put("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10121) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36");
} public enum HttpMethod {
GET,
POST,
OPTIONS,
PUT;
} @Getter
@Setter
private HttpMethod method = HttpMethod.GET; /**
* 請求頭
*/
@Setter
private Map<String, String> requestHeaders; /**
* 請求參數
*/
@Setter
private Map<String, Object> requestParams; public Map<String, String> getRequestHeaders() { return requestHeaders == null ? DEFAULT_HEADERS : requestHeaders;
} public Map<String, Object> getRequestParams() { return requestParams == null ? Collections.emptyMap() : requestParams;
}
}
建立一個
HttpUtils
工具類,來具體的執行Http請求, 下面我們暫先實作Get/Post兩個請求方式,後續可以再這裡進行擴充和優化
public class HttpUtils { public static HttpResponse request(CrawlMeta crawlMeta, CrawlHttpConf httpConf) throws Exception { switch (httpConf.getMethod()) { case GET: return doGet(crawlMeta, httpConf); case POST: return doPost(crawlMeta, httpConf); default: return null;
}
} private static HttpResponse doGet(CrawlMeta crawlMeta, CrawlHttpConf httpConf) throws Exception {// HttpClient httpClient = HttpClients.createDefault();
SSLContextBuilder builder = new SSLContextBuilder();// 全部信任 不做身份鑒定
builder.loadTrustMaterial(null, (x509Certificates, s) -> true);
HttpClient httpClient = HttpClientBuilder.create().setSslcontext(builder.build()).build(); // 設定請求參數
StringBuilder param = new StringBuilder(crawlMeta.getUrl()).append("?"); for (Map.Entry<String, Object> entry : httpConf.getRequestParams().entrySet()) {
param.append(entry.getKey())
.append("=")
.append(entry.getValue())
.append("&");
}
HttpGet httpGet = new HttpGet(param.substring(0, param.length() - 1)); // 過濾掉最後一個無效字元
// 設定請求頭
for (Map.Entry<String, String> head : httpConf.getRequestHeaders().entrySet()) {
httpGet.addHeader(head.getKey(), head.getValue());
} // 執行網絡請求
return httpClient.execute(httpGet);
} private static HttpResponse doPost(CrawlMeta crawlMeta, CrawlHttpConf httpConf) throws Exception {// HttpClient httpClient = HttpClients.createDefault();
SSLContextBuilder builder = new SSLContextBuilder();// 全部信任 不做身份鑒定
builder.loadTrustMaterial(null, (x509Certificates, s) -> true);
HttpClient httpClient = HttpClientBuilder.create().setSslcontext(builder.build()).build();
HttpPost httpPost = new HttpPost(crawlMeta.getUrl()); // 建立一個NameValuePair數組,用于存儲欲傳送的參數
List<NameValuePair> params = new ArrayList<>(); for (Map.Entry<String, Object> param : httpConf.getRequestParams().entrySet()) {
params.add(new BasicNameValuePair(param.getKey(), param.getValue().toString()));
}
httpPost.setEntity(new UrlEncodedFormEntity(params, HTTP.UTF_8)); // 設定請求頭
for (Map.Entry<String, String> head : httpConf.getRequestHeaders().entrySet()) {
httpPost.addHeader(head.getKey(), head.getValue());
} return httpClient.execute(httpPost);
}
}
然後我們的 doFetchPage 方法将簡潔很多
/**
* 執行抓取網頁
*/public void doFetchPage() throws Exception {
HttpResponse response = HttpUtils.request(crawlMeta, httpConf);
String res = EntityUtils.toString(response.getEntity()); if (response.getStatusLine().getStatusCode() == 200) { // 請求成功
doParse(res);
} else { this.crawlResult = new CrawlResult(); this.crawlResult.setStatus(response.getStatusLine().getStatusCode(), response.getStatusLine().getReasonPhrase()); this.crawlResult.setUrl(crawlMeta.getUrl());
}
}
下一步
上面我們實作的是一個最簡陋,最基礎的東西了,但是這個基本上又算是滿足了核心的功能點,但距離一個真正的爬蟲架構還差那些呢 ?
另一個核心的就是:
爬了一個網址之後,解析這個網址中的連結,繼續爬!!!
下一篇則将在本此的基礎上,考慮如何實作上面這個功能點;寫這個部落格的思路,将是先做一個實作需求場景的東西出來,,可能在開始實作時,很多東西都比較挫,相容性擴充性易用性啥的都不怎麼樣,計劃是在完成基本的需求點之後,然後再着手去優化看不順眼的地方
堅持,希望可以持之以恒,完善這個東西
源碼
項目位址: https://github.com/liuyueyi/quick-crawler
上面的分了兩步,均可以在對應的tag中找到響應的代碼,主要代碼都在core子產品下