基于Java的爬虫框架WebCollector

Long, Long Ago,网络上出现大量Python爬虫教程,各种培训班借势宣扬Python,近几年又将噱头转向人工智能。爬虫是一个可以简单也可以复杂的概念,就好比建造狗屋和建筑高楼大厦都是在搞工程。

由于工作的缘故,我需要使用WebCollector爬取一些网页上的数据。其实宏观上,爬虫无非就是访问页面文件,把需要的数据提取出来,然后把数据储存到数据库里。难点往往在于,一是目标网站的反爬策略,这是让人比较无奈的斗智斗勇的过程;二是目标网页数量大、类型多,如何制定有效的数据爬取和数据分析方案。

概述

这是一张简略的概念图,受屏幕宽度限制,可能无法看清内容,请在新标签页打开图片,或者直接点击 这里。这张图片并不是完美的,甚至还包含不完全正确的实现方式,具体内容会在后面阐述。

我将目标网页分为4种类型:

  1. 静态的网页文档,curl就可以加载到
  2. 需要自定义HTTP请求的页面,比如由POST请求得到的搜索结果页面,或者需要使用Cookie进行鉴权的页面
  3. 页面中包含由JavaScript生成的数据,而我们需要的正是这部分数据。由于js是加载后才执行的,就像CSS加载后由浏览器进行渲染一样,这样的数据无法直接得到
  4. 页面中包含由JavaScript生成的数据,且需要自定义HTTP请求的页面

测试环境

为了便于测试,在本地使用Node.js启动一个简单的服务器,用于接收请求,并返回一个页面作为响应。server.js的内容如下:

var http = require('http')
var fs = require('fs')
var server = http.createServer((req,res) => {
// 返回页面内容
fs.readFile('./index.html', 'utf-8', (err,data) => {
res.end(data);
});
// 打印请求中的Cookie信息
console.log(req.headers.cookie)
})
server.listen(9000)

index.html的内容更加简单,只包含一个title和一个p标签:

<!DOCTYPE html>
<html>
<head>
<title>This is a title</title>
</head>
<body>

</body>
</html>

静态页面

这是一个最简版的爬虫程序,在构造方法中调用父类的有参构造方法,同时添加url到待爬取队列中。visit是消费者,每一个url请求都会进入这个方法被处理。

public class StaticDocs extends BreadthCrawler {

public StaticDocs(String crawlPath, boolean autoParse) {
super(crawlPath, autoParse);
this.addSeed("http://127.0.0.1:9000/");
}

@Override
public void visit(Page page, CrawlDatums next) {
System.out.println(page.doc().title();
// This is a title
}

public static void main(String[] args) throws Exception {
StaticDocs crawler = new StaticDocs("crawl", true);
crawler.start(1);
}
}

Cookie鉴权

需要在header中带cookie请求同样简单,在构造方法中添加相应配置就可以,node.js的命令行会打印出cookie的内容:

public CookieDocs(String crawlPath) {
super(crawlPath, true);

// 设置请求插件
setRequester(new OkHttpRequester() {
@Override
public Request.Builder createRequestBuilder(CrawlDatum crawlDatum) {
return super.createRequestBuilder(crawlDatum)
.header("Cookie", "name=smallyu");
}
});

this.addSeed("http://127.0.0.1:9000/");
}

// name=smallyu

JavaScript生成的数据

测试js生成数据的情况需要做一点准备,修改index.html,在body标签中加入这样几行代码:

<div id="content">1</div>
<script>
document.getElementById('content').innerHTML = '2'
</script>

可以预见,请求中直接返回的div内容是1,然后js经由浏览器执行,改变div的内容为2。访问静态页面的爬虫程序只能进行到第1步,也就是直接获取请求返回的内容。修改StaticDocs.java的visit方法,打印出div的内容看一下,可以确信是1:

System.out.println(page.select("div").text());
// 1

这是一个官方提供的Demo,用于获取js生成的数据。WebCollector依赖于Selenium,使用HtmlUnitDriver运行js:

public class JsDocs {
public static void main(String[] args) throws Exception {
Executor executor = (CrawlDatum datum, CrawlDatums next) -> {
HtmlUnitDriver driver = new HtmlUnitDriver();
driver.setJavascriptEnabled(true);

driver.get(datum.url());

WebElement divEle = driver.findElement(By.id("content"));
System.out.println(divEle.getText());
// 2
};

//创建一个基于伯克利DB的DBManager
DBManager manager = new RocksDBManager("crawl");
//创建一个Crawler需要有DBManager和Executor
Crawler crawler = new Crawler(manager, executor);
crawler.addSeed("http://127.0.0.1:9000/");
crawler.start(1);
}
}

如果你看过WebCollector的主页,一定可以注意到这个Demo和其他Demo的明显不同。在不需要js生成的数据时,新建的类继承自BreadthCrawler,而BreadthCrawler继承自AutoParseCrawler,AutoParseCrawler又继承自Crawler。现在获取js数据的Demo,直接跳过BreadthCrawler和AutoParseCrawler,实例化了Crawler。

为什么要这样做呢?再次强调,这是官方提供的Demo。

Cookie鉴权后JavaScript生成的数据

根据官方提供的用例,显然是无法设置cookie的,因为Crawler类并没有setRequester()这个方法。setRequester继承自AutoParseCrawler类。那么如何做到既可以定义cookie又可以使用HtmlUnitDriver?

其实结果很简单,我在看过WebCollector的代码后发现AutoParseCrawler实现了Executor接口,并且在构造方法中将this赋值给了父类的executor。也就是说,AutoParseCrawler本身就是一个Executor。下面的代码用以表示它们的关系:

public class Crawler {
protected Executor executor;

public Crawler(DBManager dbManager, Executor executor) {
// ...
}
}

public class AutoParseCrawler extends Crawler implements Executor {
public AutoParseCrawler(boolean autoParse) {
// 这里的executor指向父类
this.executor = this;
}
}

new Crawler时传入一个executor,相当于直接new一个AutoParseCrawler。BreadthCrawler继承自AutoParseCrawler,所以BreadthCrawler本身也是个Executor。再看官方Demo,如何使用HtmlUnitDriver呢?重写Executor的execute方法。

所以,在定义cookie后获取js生成的数据,使用继承BreadthCrawler的类,然后重写execute就可以。这是一个完整的Demo:

/**
* @author smallyu
* @date 2019.08.11 12:18
*/
public class JsWithCookieDocs extends BreadthCrawler {

public JsWithCookieDocs(String crawlPath) {
super(crawlPath, true);

// 设置请求插件
setRequester(new OkHttpRequester() {
@Override
public Request.Builder createRequestBuilder(CrawlDatum crawlDatum) {
return super.createRequestBuilder(crawlDatum)
.header("Cookie", "name=smallyu");
}
});

this.addSeed("http://127.0.0.1:9000/");
}

// 直接重写execute即可
@Override
public void execute(CrawlDatum datum, CrawlDatums next) throws Exception {
super.execute(datum, next);

HtmlUnitDriver driver = new HtmlUnitDriver();
driver.setJavascriptEnabled(true);

driver.get(datum.url());

WebElement divEle = driver.findElement(By.id("content"));
System.out.println(divEle.getText());
// 2
// 同时,node.js的命令行中打印出cookie内容
}

// 重写execute就不需要visit了
public void visit(Page page, CrawlDatums crawlDatums) {}

public static void main(String[] args) throws Exception {
JsWithCookieDocs crawler = new JsWithCookieDocs("crawl");
crawler.start(1);
}
}

外部代理

也许还没有结束。在一开始概述的图片上,同时定义cookie以及获取js生成的数据,实现方式是内部Selenium + 外部browsermob-proxy。假设没有上述重写execute的方法(官方也确实没有提供类似的Demo),该如何实现想要的效果?一种实践是本地启动一个代理,给代理设置好cookie,然后让Selenium的WebDriver通过代理访问目标页面,就可以在带header的情况下拿到js生成的数据。这是在JsDocs.java的基础上,使用代理的完整实现:

public class JsWithProxyDocs {
public static void main(String[] args) throws Exception {
Executor executor = (CrawlDatum datum, CrawlDatums next) -> {

// 启动一个代理
BrowserMobProxy proxy = new BrowserMobProxyServer();
proxy.start(0);
// 添加header
proxy.addHeader("Cookie" , "name=smallyu");

// 实例化代理对象
Proxy seleniumProxy = ClientUtil.createSeleniumProxy(proxy);
// 由代理对象生成capabilities
DesiredCapabilities capabilities = new DesiredCapabilities();
capabilities.setCapability(CapabilityType.PROXY, seleniumProxy);
// 内置,必须设置
capabilities.setBrowserName("htmlunit");

// 使用capabilities实例化HtmlUnitDriver
HtmlUnitDriver driver = new HtmlUnitDriver(capabilities);
driver.setJavascriptEnabled(true);

driver.get(datum.url());

WebElement divEle = driver.findElement(By.id("content"));
System.out.println(divEle.getText()); // 2
};

//创建一个Crawler需要有DBManager和Executor
Crawler crawler = new Crawler(new RocksDBManager("crawl"), executor);
crawler.addSeed("http://127.0.0.1:9000/");
crawler.start(1);
}
}

其他

对于WebCollector我已经没有兴趣了解更多,倒是在注意到框架的包名cn.edu.hfut后有种豁然开朗的感觉。凌乱的代码风格,随处可见不知所以的注释,毫无设计美感的代码架构,倒也符合国内不知名大学的开源软件水平,距离工业级的框架,可能还需要N个指数倍东的时间。至于使用过程中遇到depth含义不明、线程非法结束、next.add失效等问题,就这样吧,也在情理之中,整个框架都像是赶工的结果,或者说是学生们拿来练手的项目。我在WebCollector的Github上RP了关于重写execute的问题,从开发者回复的只言片语中,我怀疑开源者自己都没有把里面的东西搞清楚 :P