Java 動手寫爬蟲: 1、實現一個最簡單爬蟲

第一篇

準備寫個爬蟲, 能夠怎麼搞?html

使用場景

先定義一個最簡單的使用場景,給你一個url,把這個url中指定的內容爬下來,而後中止java

  • 一個待爬去的網址(有個地方指定爬的網址)
  • 如何獲取指定的內容(能夠配置規則來獲取指定的內容)

設計 & 實現

1. 基本數據結構

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

2. 爬取任務

爬取網頁的具體邏輯就放在這裏了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);
    }
}

4. 測試

上面一個最簡單的爬蟲就完成了,就須要拉出來看看,是否能夠正常的工做了

就拿本身的博客做爲測試網址,目標是獲取 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的使用方式

t1.gif

優化

  1. 上面完成以後,有個地方看着就不太舒服,doFetchPage 方法中的抓去網頁,有很多的硬編碼,並且那麼一大串看着也不太利索, 因此考慮加一個配置項,用於記錄HTTP相關的參數

  2. 能夠用更成熟的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
@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");

2. http配置項

顯然每次都這麼手動塞入參數是不可選的,咱們有必要透出一個接口,由用戶本身來指定一些請求和返回參數

首先咱們能夠確認下都有些什麼樣的配置項

  • 請求方法: GET, POST, OPTIONS, DELET ...
  • RequestHeader: Accept Cookie Host Referer User-Agent Accept-Encoding Accept-Language ... (直接打開一個網頁,看請求的hedaers便可)
  • 請求參數
  • ResponseHeader: 這個咱們無法設置,可是咱們能夠設置網頁的編碼(這個來fix中文亂碼比較使用)
  • 是否走https(這個暫時能夠不考慮,後面討論)

新增一個配置文件,配置參數主要爲

  • 請求方法
  • 請求參數
  • 請求頭
@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

相關文章
相關標籤/搜索