準備寫個爬蟲, 能夠怎麼搞?html
先定義一個最簡單的使用場景,給你一個url,把這個url中指定的內容爬下來,而後中止java
CrawlMeta.java
一個配置項,包含塞入的 url 和 獲取規則node
/** * Created by yihui on 2017/6/27. */ @ToString public class CrawlMeta { /** * 待爬去的網址 */ @Getter @Setter private String url; /** * 獲取指定內容的規則, 由於一個網頁中,你可能獲取多個不一樣的內容, 因此放在集合中 */ @Setter private Set<String> selectorRules; // 這麼作的目的就是爲了防止NPE, 也就是說支持不指定選擇規則 public Set<String> getSelectorRules() { return selectorRules != null ? selectorRules : new HashSet<>(); } }
CrawlResult
抓取的結果,除了根據匹配的規則獲取的結果以外,把整個html的數據也保存下來,這樣實際使用者就能夠更靈活的從新定義獲取規則git
import org.jsoup.nodes.Document; @Getter @Setter @ToString public class CrawlResult { /** * 爬取的網址 */ private String url; /** * 爬取的網址對應的 DOC 結構 */ private Document htmlDoc; /** * 選擇的結果,key爲選擇規則,value爲根據規則匹配的結果 */ private Map<String, List<String>> result; }
說明:這裏採用jsoup來解析htmlgithub
爬取網頁的具體邏輯就放在這裏了web
一個爬取的任務
CrawlJob
,爬蟲嘛,正常來說都會塞到一個線程中去執行,雖然咱們是第一篇,也不至於low到直接放到主線程去作編程面向接口編程,因此咱們定義了一個
IJob
的接口設計模式
IJob.java
這裏定義了兩個方法,在job執行以前和以後的回調,加上主要某些邏輯能夠放在這裏來作(如打日誌,耗時統計等),將輔助的代碼從爬取的代碼中抽取,使代碼結構更整潔數組
public interface IJob extends Runnable { /** * 在job執行以前回調的方法 */ void beforeRun(); /** * 在job執行完畢以後回調的方法 */ void afterRun(); }
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
一個最簡單的實現類,直接利用了JDK的URL方法來抓去網頁,而後利用jsoup進行html結構解析,這個實現中有較多的硬編碼,先看着,下面就着手第一步優化
/** * 最簡單的一個爬蟲任務 * <p> * Created by yihui on 2017/6/27. */ @Getter @Setter public 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); } }
上面一個最簡單的爬蟲就完成了,就須要拉出來看看,是否能夠正常的工做了
就拿本身的博客做爲測試網址,目標是獲取 title + content,因此測試代碼以下
/** * 測試咱們寫的最簡單的一個爬蟲, * * 目標是爬取一篇博客 */ @Test public void testFetch() throws InterruptedException { String url = "https://my.oschina.net/u/566591/blog/1031575"; 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的使用方式
上面完成以後,有個地方看着就不太舒服,
doFetchPage
方法中的抓去網頁,有很多的硬編碼,並且那麼一大串看着也不太利索, 因此考慮加一個配置項,用於記錄HTTP相關的參數能夠用更成熟的http框架來取代jdk的訪問方式,維護和使用更加簡單
僅針對這個最簡單的爬蟲,咱們開始着手上面的兩個優化點
使用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 @AllArgsConstructor static 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 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36");
顯然每次都這麼手動塞入參數是不可選的,咱們有必要透出一個接口,由用戶本身來指定一些請求和返回參數
首先咱們能夠確認下都有些什麼樣的配置項
Accept
Cookie
Host
Referer
User-Agent
Accept-Encoding
Accept-Language
... (直接打開一個網頁,看請求的hedaers便可)新增一個配置文件,配置參數主要爲
@ToString public 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 10_12_1) 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模塊下
第一步對應的tag爲:v0.001
優化後對應的tag爲:v0.002