基于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;// 网络链接,获取网页documenttry {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();@Overridepublic 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());}@Overridepublic 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;}@Overrideprotected 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 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 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


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部