基于java语言的百科实体的多线程爬虫(Java+Selenium+Jsoup)

基于java语言的百科实体的多线程爬虫

一、实现功能

  • 利用爬虫爬取人物的简介,以及其关联人物,并将其保存到文件中,例如对于人物“陈信宏”,可以得到的
    • 百科简介为
      image-20211105211427978

    • 关联人物为:

      image-20211105211409191

二、技术要点

  • 爬虫html页面下载部分采用两种方式(喜欢那种用那种,代码中以可插拔的方式实现)

    • 对于正常的比较容易拿到url的页面,如百度百科等,直接利用Jsoup库下载html页面

    • 对于查询的关键词没有在url中直接体现的情况(如搜狗百科),比较笨的办法是可以使用模拟浏览器的方式,这里采用的是开源工具selenium模拟谷歌浏览器获取要爬取的页面

      image-20211105211739651

  • 爬虫爬取部分采用的是ForkJoin框架,因为最近在看《Java并发编程的艺术》这本书,刚好看到ForkJoin框架这里,就想写一个简单的东西巩固一下知识点。ForkJoin框架的好处在于对于比较简单的任务代码编写起来比较简单,而且自身采用的是分治法的思想,效率也很高。关于ForkJoin框架的介绍有很多,这里不在赘述。这里当然也可以使用线程池

三、具体实现

1. WebDriver的配置(针对2中对于查询的关键词没有在url中直接体现的情况)

本文使用的是Chrome浏览器,因此需要提前下载好chromedriver.exe文件,下载地址:ChromeDriver Mirror (taobao.org),注意下载的chromedriver.exe文件需要与自己的谷歌浏览器版本对应。

要点:

  • ChromeDriver类这里使用的是懒汉式静态内部类的方式进行单例模式初始化,这样做可以避免多线程情况下出现的问题。(提示:也可以使用双重检查的方式初始化,注意加volatile)
  • ChromeDriver类中可以传入ChromeOptions参数帮助配置

配置代码如下:

public class BrowerChromeDriver {
    public static ChromeOptions defaultChromeOptions(){
        ChromeOptions chromeOptions = new ChromeOptions();
        chromeOptions.addArguments("no-sandbox");
        // 爬虫运行时隐藏浏览器页面,可以取消注释观察效果
        chromeOptions.addArguments("headless");
        return chromeOptions;
    }
    public static ChromeDriver defaultChromeDriver(){
        System.setProperty("webdriver.chrome.driver", CHROME_DRIVER_PATH);
        ChromeDriver driver = new ChromeDriver(defaultChromeOptions());
        driver.manage().timeouts().pageLoadTimeout(5000, TimeUnit.MILLISECONDS);
        return driver;
    }
    public static class ChromeDriverClass{
        public static  ChromeDriver driver=  defaultChromeDriver();
    }
}

2. 使用Jsoup库进行html页面下载解析

主要获取百科实体的摘要,和关联实体信息。

要点:

  • 使用Jsoup库访问对应url时, 超时时间设置的长一些,不然容易因为网络问题链接失败。
  • 这里为了保存爬取到的人物信息,创建了一个BaikeEntity类,方便日后做功能扩展(如将信息存入数据库等)
  • 解析页面时尽量选取具有唯一性的标签,利用select()方法进行标签元素的获取,注意区分Elements和Element
@Slf4j
public class JsoupSpider implements Spider<BaikeEntity>{
    private String entityName;
    public BaikeEntity get(String url){
        StringBuilder sb = new StringBuilder();
        BaikeEntity baikeEntity = new BaikeEntity();
        log.info("开始获取百度百科网页,实体为:{}",entityName);
        Document document =null;
        // 网络链接,获取网页document
        try {
            document = Jsoup.connect(url).userAgent(USER_AGENT).timeout(5000).get();
        } catch (IOException e) {
            log.error("{} 网页加载失败",entityName);
            e.printStackTrace();
        }
        Elements elements = document.select("div[class='lemma-summary']");
        for(Element element:elements){
            for(Element e:element.children()){
                String text = e.select("div[class='para']").text();
                if(text.length()!=0){
                    sb.append(text);
                    sb.append("\n");
                }
            }
        }
        // 可能会存在百科页面中不包含实体关系信息的情况
        try{
            Element relation = document.selectFirst("div[id='slider_relations']");
            Elements es = relation.select("a[class='J-relations-item']");
            for(Element e: es){
                String entityName = e.attr("data-title");
                String text = e.text();
                String relationName = text.substring(0,text.indexOf(entityName));
                BaikeRelation entityRelation = new BaikeRelation(relationName);
                baikeEntity.getRelationMap().put(entityName,entityRelation);
            }
        }catch(Exception e){
            log.error("百科页面不存在与实体:{} 关联的实体",entityName);
        }
        log.info("实体为:{}的百科网页解析完成",entityName);
        String abstractText = sb.toString();
        baikeEntity.setEntityName(entityName);
        baikeEntity.setAbstractText(abstractText);
        return baikeEntity;
    }
    public BaikeEntity getEntity(String entityName){
        this.entityName=entityName;
        return get(BAIKE_URL+entityName);
    }
    public static void main(String[] args) {
        JsoupSpider jsoupSpider = new JsoupSpider();
        BaikeEntity txt = jsoupSpider.getEntity("陈信宏");
    }
}

3. 使用Selenium模拟浏览器的方式获取页面信息

以访问搜狗百科为例,获取搜狗百科下实体的摘要,这里省略了关联实体的解析,感兴趣的可以自己尝试一下。

要点:

  • 为了防止反复打开新的浏览器应用,这里采用的方式是:每次来新的请求只打开新的浏览器标签。
    • 这里需要注意的是,每个浏览器的标签都有一个handle,而同一时刻,程序只能获得浏览器一个标签的handle,当我们新打开一个浏览器标签时,我们的程序获得的浏览器的handle还停留在之前的标签上,因此需要更新获取的handle。
    • 更新handle的方式是每次新建标签窗口时,都利用方法driver.getWindowHandles()获取浏览器的全部标签的handle,这里的handleSetLinkedHashSet类型,这个类型的元素顺序是有序的(这里的有序并非是按着大小排序,而是说你插入的是什么顺序,元素就一直保持那个顺序不变,如果不人为排序的话),这样这个set的最后一个元素就是新建的标签的handle。
    • 为了避免并发问题,需要对这部分代码加独占锁,这里使用ReentrantLock,可以自己去掉锁看一下会出什么问题
@Slf4j
public class SeleniumSpider implements Spider<BaikeEntity> {
    private final ChromeDriver driver = BrowerChromeDriver.ChromeDriverClass.driver;
    private volatile Map<String,String> handleMap = new ConcurrentHashMap<>();
    private final Lock lockPage = new ReentrantLock();
    @Override
    public BaikeEntity get(String entityName) {
        String htmlText;
        // 打开新窗口,并将driver句柄切换到新窗口
        lockPage.lock();
        try {
            driver.executeScript("window.open()");
            Set<String> handleSet = driver.getWindowHandles();
            List<String> tmpHandleList = new ArrayList<>(handleSet);
            String curHandle = tmpHandleList.get(tmpHandleList.size() - 1);
            driver.switchTo().window(curHandle);
            driver.get(SOUGOU_URL);
            handleMap.put(entityName,curHandle);
            driver.switchTo().window(handleMap.get(entityName));
            driver.findElement(By.id("searchText")).sendKeys(entityName);
            driver.findElement(By.id("enterLemma")).click();
        }finally {
            lockPage.unlock();
        }
        htmlText = driver.getPageSource();
        Document document = Jsoup.parse(htmlText);
        StringBuilder sb = new StringBuilder();
        Elements elements = document.select("div[class='abstract_main']");

        for(Element e:elements){
            for(Element e1:e.children()){
                if(e1.hasClass("abstract")){
                    for(Element ee: e1.children()){
                        String text = ee.select("p").text();
                        if(text.length()!=0){
                            sb.append(text);
                            sb.append("\n");
                        }
                    }
                }
            }
        }
        BaikeEntity entity = new BaikeEntity();
        entity.setEntityName(entityName);
        entity.setAbstractText(sb.toString());
        log.info("实体为{}的页面解析完成",entityName);
        return entity;
    }
    public static void main(String[] args) {
        SeleniumSpider seleniumSpider = new SeleniumSpider();
        BaikeEntity e = seleniumSpider.getEntity("成龙");
        System.out.println(e.getAbstractText());
    }


    @Override
    public BaikeEntity getEntity(String entityName) {
        return get(entityName);
    }
}

4. 核心部分:ForkJoin框架

利用并发框架执行爬虫操作,最后将得到的结果保存到文件中

要点:

  • 建造者模式实例化ForkJoinSpiderTask:将构造函数私有化,采用静态方法builder进行创建实例,并使用set方法设置参数,可以使得方法调用者无序关心该类的具体结构。
  • 核心部分采用的是分治思想,设置参数数组的其实索引位置和结束索引位置,如果索引间隔小于设定的阈值,则可以执行对应的操作,否则需要继续分隔任务。
@Slf4j
public class ForkJoinSpiderTask extends RecursiveTask<List<BaikeEntity>> {
    private int threshold;
    private String[] entityList;
    private int start;
    private int end;
    private Spider spider;
    private Set<BaikeEntity> set = new HashSet<>();
    private ForkJoinSpiderTask(){}

    public static ForkJoinSpiderTask builder() {
        return new ForkJoinSpiderTask();
    }
    public ForkJoinSpiderTask setThreshold(int threshold){
        this.threshold=threshold;
        return this;
    }
    public ForkJoinSpiderTask setEntityList(String[] entityList){
        this.entityList=entityList;
        return this;
    }
    public ForkJoinSpiderTask setStartIndex(int start){
        this.start=start;
        return this;
    }
    public ForkJoinSpiderTask setEndIndex(int start){
        this.end=start;
        return this;
    }
    public ForkJoinSpiderTask setSpider(Spider spider){
        this.spider=spider;
        return this;
    }
    @Override
    protected List<BaikeEntity> compute() {
        List<BaikeEntity> result = new ArrayList<>();
        if((end-start)<=threshold){
            for(int i=start;i<end;++i){
                BaikeEntity baikeEntity = (BaikeEntity) spider.getEntity(entityList[i]);
                result.add(baikeEntity);
                // 将爬取到的信息存入文件中
                FileWriter fileWriter = null;
                Map<String, BaikeRelation> relationMap = baikeEntity.getRelationMap();
                try {
                    // 文件输出的目录, 最好不要用中文命名
                    String path="E:\\***\\seleniumSpider\\target\\out\\";
                    File file = new File(path+entityList[i]+".txt");
                    fileWriter = new FileWriter(file);
                    fileWriter.append(entityList[i]);
                    fileWriter.append('\n');
                    fileWriter.append(baikeEntity.getAbstractText());
                    fileWriter.append("实体关系"+'\n');
                    for(Map.Entry<String,BaikeRelation> e :relationMap.entrySet()){
                        fileWriter.append(e.getKey())
                                .append(" : ")
                                .append(String.valueOf(e.getValue().getRelationName()))
                                .append(String.valueOf('\n'));
                    }
                    fileWriter.close();
                } catch (IOException e) {
                    e.printStackTrace();
                    log.error("实体为{}的信息保存到文件失败",entityList[i]);
                }
            }
        }else{
            int mid = (start+end)/2;
            ForkJoinSpiderTask leftTask = new ForkJoinSpiderTask();
            ForkJoinSpiderTask rightTask = new ForkJoinSpiderTask();
            leftTask.setThreshold(threshold).setStartIndex(start).setEndIndex(mid).setSpider(spider).setEntityList(entityList);
            rightTask.setThreshold(threshold).setStartIndex(mid).setEndIndex(end).setSpider(spider).setEntityList(entityList);
            // 执行任务
            leftTask.fork();
            rightTask.fork();
            //阻塞,等待其他线程执行结果
            List<BaikeEntity> leftJoin = leftTask.join();
            List<BaikeEntity> rightJoin = rightTask.join();
            result.addAll(leftJoin);
            result.addAll(rightJoin);
        }
        return result;
    }
}

5. 测试

  • 可以将多线程的执行时间与单线程条件下的执行时间进行比较,以Jsoup爬虫为例,在不进行爬取结果存储的条件下,多线程下的时间大概为2894ms,而单线程下时间大概为3782ms,如果任务量更大,那多线程的时间优势将更明显。
public static void main(String[] args) {
    long startTime = System.currentTimeMillis();
    ForkJoinPool forkJoinPool = new ForkJoinPool();
    String[] list = new String[]{"成龙","周润发","周星驰","刘德华","张学友","周杰伦","梁朝伟","王力宏","五月天","张家辉","陈奕迅","林俊杰","陈绮贞"};
    Spider spider= new SeleniumSpider();
    //        Spider<BaikeEntity> spider= new JsoupSpider();
    ForkJoinSpiderTask task = ForkJoinSpiderTask.builder()
        .setThreshold(3)
        .setStartIndex(0)
        .setEndIndex(list.length)
        .setSpider(spider)
        .setEntityList(list);
    //                .setSpider(spider)
    ForkJoinTask<List<BaikeEntity>> submit = forkJoinPool.submit(task);
    try {
        // 爬虫结果
        List<BaikeEntity> list1 = submit.get();
        // 关闭浏览器
        if(spider instanceof SeleniumSpider){
            BrowerChromeDriver.ChromeDriverClass.driver.quit();
        }
        long end = System.currentTimeMillis();
        System.out.println("耗费时间"+ (end - startTime));
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (ExecutionException e) {
        e.printStackTrace();
    }

    // 单线程爬虫时间
    //        long s = System.currentTimeMillis();
    //        SpiderSougou jsoupSpider = new SpiderSougou();
            JsoupSpider jsoupSpider = new JsoupSpider();
    //        List<BaikeEntity> ans = new ArrayList<>();
    //
    //        for(String e:list){
    //            BaikeEntity baikeEntity = jsoupSpider.getEntity(e);
    //            ans.add(baikeEntity);
    //        }
    //        long e = System.currentTimeMillis();
    //        System.out.println(e-s);
    //        System.out.println();
}

四、总结

  • 本爬虫功能较为基础,但是很多地方可以进行人为定制与扩展,例如在基本的ForkJoin框架不变的条件下,可以自定义爬虫类,利用该框架进行自定义爬虫的多线程爬取操作

  • 本文利用的是ForkJoin框架,此外还可以利用线程池的方法实现。

  • 本文代码的耦合性还是略高,为了符合低耦合的设计模式要求,可以将爬虫部分再细化一下,分成下载器、解析器和存储器等功能类,以聚合的方式存在于爬虫类中,这样下载器,解析器,存储器都可以人工定制,代码耦合性较低。

  • 完整代码(喜欢的同学拜托给个star): github

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值