天天看點

親自動手寫爬蟲系列一、實作一個最簡單爬蟲

第一篇

準備寫個爬蟲, 可以怎麼搞?

使用場景

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

  • 一個待爬去的網址(有個地方指定爬的網址)
  • 如何擷取指定的内容(可以配置規則來擷取指定的内容)

設計 & 實作

1. 基本資料結構

​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​

抓取的結果,除了根據比對的規則擷取的結果之外,把整個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​

這裡定義了兩個方法,在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@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的使用方式

親自動手寫爬蟲系列一、實作一個最簡單爬蟲

優化

  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@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​

    ​ ​

    ​Accept-Language​

    ​ … (直接打開一個網頁,看請求的hedaers即可)
  • 請求參數
  • 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子產品下