基于java语言的百科实体的多线程爬虫
一、实现功能
- 利用爬虫爬取人物的简介,以及其关联人物,并将其保存到文件中,例如对于人物“陈信宏”,可以得到的
-
百科简介为
-
关联人物为:
-
二、技术要点
-
爬虫html页面下载部分采用两种方式(喜欢那种用那种,代码中以可插拔的方式实现)
-
对于正常的比较容易拿到url的页面,如百度百科等,直接利用Jsoup库下载html页面
-
对于查询的关键词没有在url中直接体现的情况(如搜狗百科),比较笨的办法是可以使用模拟浏览器的方式,这里采用的是开源工具selenium模拟谷歌浏览器获取要爬取的页面
-
-
爬虫爬取部分采用的是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,这里的handleSet
是LinkedHashSet
类型,这个类型的元素顺序是有序的(这里的有序并非是按着大小排序,而是说你插入的是什么顺序,元素就一直保持那个顺序不变,如果不人为排序的话),这样这个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