Java 动手写爬虫: 一、实现一个最简单爬虫

第一篇

准备写个爬虫, 可以怎么搞?

使用场景

先定义一个最简单的使用场景,给你一个url,把这个url中指定的内容爬下来,然后停止

  • 一个待爬去的网址(有个地方指定爬的网址)
  • 如何获取指定的内容(可以配置规则来获取指定的内容)

设计 & 实现

1. 基本数据结构

CrawlMeta.java

一个配置项,包含塞入的 url 和 获取规则

/**
 * 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的数据也保存下来,这样实际使用者就可以更灵活的重新定义获取规则

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来解析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
@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的使用方式

优化

  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

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏恰童鞋骚年

自己动手写一个简单的MVC框架(第一版)

  路由(Route)、控制器(Controller)、行为(Action)、模型(Model)、视图(View)

16720
来自专栏ASP.NET MVC5 后台权限管理系统

ASP.NET MVC5+EF6+EasyUI 后台管理系统(21)-权限管理系统-跑通整个系统

这一节我们来跑通整个系统,验证的流程,通过AOP切入方式,在访问方法之前,执行一个验证机制来判断是否有操作权限(如:增删改等) 原理:通过MVC自带筛选器,在筛...

79170
来自专栏Porschev[钟慰]的专栏

asp.net生成静态页

做个生成静态页示例: 采用替换模版页的形式生成静态页 第一步:新建项目,创建一个简单模版页:TemplatePage.htm <!DOCTYPE html PU...

23360
来自专栏技术专栏

使用apache的httpClient对异步网络请求进行封装

14910
来自专栏魂祭心

原 WCF学习之旅----基础篇之Ente

28260
来自专栏大内老A

在ASP.NET MVC中通过URL路由实现对多语言的支持

对于一个需要支持多语言的Web应用,一个很常见的使用方式就是通过请求地址来控制界面呈现所基于的语言文化,比如我们在表示请求地址的URL中将上语言文化代码(比如e...

23460
来自专栏大内老A

ASP.NET Process Model之二:ASP.NET Http Runtime Pipeline - Part II

二、ASP.NET Runtime Pipeline(续ASP.NET Http Runtime Pipeline - Part I) 现在我们真正进入ASP....

21780
来自专栏张高兴的博客

张高兴的 Windows 10 IoT 开发笔记:无线收发芯片 nRF24L01

31680
来自专栏有趣的django

Django rest framework(7)----分页

第一种分页  PageNumberPagination 基本使用 (1)urls.py urlpatterns = [ re_path('(?P<ve...

66470
来自专栏ASP.NET MVC5 后台权限管理系统

ASP.NET MVC5+EF6+EasyUI 后台管理系统(30)-本地化(多语言)

我们的系统有时要扩展到其他国家,或者地区,需要更多的语言环境,微软提供了一些解决方案,原始我们是用js来控制的,现在不需要了。 我们只要创建简单的资源文件,通过...

35670

扫码关注云+社区

领取腾讯云代金券