在前面两节中,向读者介绍了Heritrix的启动、创建任务、抓取网页、组件结构。但是,读者应该也可以明显的看出,如果不用Heritrix抓取和分析网页的行为进行一定的控制,它是无法达到要求的。
对Heritrix的行为进行控制,是要建立在对其架构充分了解的基础之上的,因此,本节的内容完全是基于上一节中所讨论的基础。
很明显,Heritrix内嵌的Extractor并不能够很好的完成所需要的工作,这不是说它不够强大,而是因为在解析一个网页时,常常有特定的需要。比如,可能只想抓取某种格式的链接,或是抓取某一特定格式中的文本片断。Heritrix所提供的大众化的Extractor只能够将所有信息全部抓取下来。在这种情况下,就无法控制Heritrix到底该抓哪些内容,不该抓哪些内容,进而造成镜象信息太复杂,不好建立索引。
以下就使用一个实例,来讲解该如何定制和使用Extractor。这个实例其实很简单,主要功能就是抓取所有在Sohu的新闻主页上出现的新闻,并且URL格式如下所示。
http://news.sohu.com/20061122/n246553333.shtml
(1)分析一下这个URL可以知道,其中的主机部分是http://news.sohu.com,这是搜狐新闻的域名,“20061122”应该表示的是新闻的日期,而最后的“n246553333.shtml”应该是一个新闻的编号,该编号全部以“n”打头。
(2)有了这样的分析,就可以根据URL的特点,来定出一个正则表达式,凡是当链接符合该正则表达式,就认为它是一个潜在的值得抓取的链接,将其收藏,以待抓取。正则表达式如下:
http://news.sohu.com/[\\d]+/n[\\d]+.shtml
(3)事实上所有的Extractor均继承自org.archive.crawler.extractor.Extractor这个抽象基类,在它的内部实现了innerProcess方法,以下便是innerProcess的实现:
代码10.10
public void innerProcess(CrawlURI curi) {
try {
/*
* 处理链接
*/
extract(curi);
} catch (NullPointerException npe) {
curi.addAnnotation("err=" + npe.getClass().getName());
curi.addLocalizedError(getName(), npe, "");
logger.log(Level.WARNING, getName() + ": NullPointerException", npe);
} catch (StackOverflowError soe) {
curi.addAnnotation("err=" + soe.getClass().getName());
curi.addLocalizedError(getName(), soe, "");
logger.log(Level.WARNING, getName() + ": StackOverflowError", soe);
} catch (java.nio.charset.CoderMalfunctionError cme) {
curi.addAnnotation("err=" + cme.getClass().getName());
curi.addLocalizedError(getName(), cme, "");
logger.log(Level.WARNING, getName() + ": CoderMalfunctionError", cme);
}
}
这个方法中,大部分代码都用于处理在解析过程中发生的各种异常和日志写入,不过,它为所有的Extractor定义了新的一个接口extract(CrawlURI),也就是说,所有的Extractor继承自它后,只需实现extract方法就可以了。以下是扩展Extractor时要做的几件事:
(1)写一个类,继承Extractor的基类。
(2)在构造函数中,调用父类的构造函数,以形成完整的家族对象。
(3)继承extract(curi)方法。
为了实现抓取news.sohu.com首页上所有新闻的链接,所开发的Extractor的完整源代码如下所示。
代码10.11
package my;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.httpclient.URIException;
import org.archive.crawler.datamodel.CrawlURI;
import org.archive.crawler.extractor.Extractor;
import org.archive.crawler.extractor.Link;
import org.archive.io.ReplayCharSequence;
import org.archive.util.HttpRecorder;
public class SohuNewsExtractor extends Extractor {
private static Logger logger = Logger.getLogger(SohuNewsExtractor.class
.getName());
// 构造函数
public SohuNewsExtractor(String name) {
this(name, "Sohu News Extractor");
}
// 构造函数
public SohuNewsExtractor(String name, String description) {
super(name, description);
}
// 第一个正则式,用于匹配SOHU新闻的格式
public static final String PATTERN_SOHU_NEWS =
"http://news.sohu.com/[\\d]+/n[\\d]+.shtml";
// 第二个正则式,用于匹配所有的<a href="xxx">
public static final String PATTERN_A_HREF =
"<a\\s+href\\s*=\\s*(\"([^\"]*)\"|[^\\s>])\\s*>";
// 继承的方法
protected void extract(CrawlURI curi) {
// 将链接对象转为字符串
String url = curi.toString();
/*
* 下面一段代码主要用于取得当前链接的返回 字符串,以便对内容进行分析时使用
*/
ReplayCharSequence cs = null;
try {
HttpRecorder hr = curi.getHttpRecorder();
if (hr == null) {
throw new IOException("Why is recorder null here?");
}
cs = hr.getReplayCharSequence();
} catch (IOException e) {
curi.addLocalizedError(this.getName(), e,
"Failed get of replay char sequence " + curi.toString()
+ " " + e.getMessage());
logger.log(Level.SEVERE, "Failed get of replay char sequence in "
+ Thread.currentThread().getName(), e);
}
// 如果什么也没抓取到,就返回
if (cs == null) {
return;
}
// 将链接返回的内容转成字符串
String content = cs.toString();
try {
// 将字符串内容进行正则匹配
// 取出其中的链接信息
Pattern pattern = Pattern.compile(PATTERN_A_HREF,
Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(content);
// 若找到了一个链接
while (matcher.find()) {
String newUrl = matcher.group(2);
// 查看其是否为SOHU新闻的格式
if (newUrl.matches(PATTERN_SOHU_NEWS)) {
// 若是,则将链接加入到队列中
// 以备后续处理
addLinkFromString(curi, newUrl, "", Link.NAVLINK_HOP);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 将链接保存记录下来,以备后续处理
private void addLinkFromString(CrawlURI curi, String uri,
CharSequence context, char hopType) {
try {
curi.createAndAddLinkRelativeToBase(uri, context.toString(),
hopType);
} catch (URIException e) {
if (getController() != null) {
getController().logUriError(e, curi.getUURI(), uri);
} else {
logger.info("Failed createAndAddLinkRelativeToBase "
+ curi + ", " + uri + ", " + context + ", "
+ hopType + ": " + e);
}
}
}
}
在上面代码的extract()方法中:
(1)首先是将Fetcher所获得的链接的HTML响应取得,并转成字符串,这样,才有可能在后面对页面中的链接做处理。
(2)从页面内容中,使用正则式取出所有链接的内容。判断链接是否符合Sohu的新闻格式,倘若符合,则调用addLinkFromString()方法,来将这个链接加入到某个队列缓存中,以备后续的处理。
在Extractor类开发完毕后,如果使用WebUI的方式启动Heritrix,并让它出现在下拉选项中,则需要修改Eclipse工程中的modules目录下的Processor.options文件,如图10-55所示。
图10-55 修改Processor.options文件
打开Processor.options文件可以看到,所有在WebUI中设置处理器链时,页面上的下拉列表中的数据都保存在了其中,为了加入我们开发的SohuNewsExtractor,只需在其中合适的位置上加入一行,内容如下所示:
my.SohuNewsExtractor|SohuNewsExtractor
接下来,再次启动Heritrix,创建一个任务,进入处理器链设置的页面,就可以看到自己开发的Extractor了,如图10-56所示。
图10-56 新加入的Extractor已经在下拉菜单中显示出来
选择后,单击“Add”按钮,就可以将其加入到队列中,如图10-57所示。
图10-57 已经加入到处理器队列中
需要注意的是,一定要将其置于ExtractorHTTP的后面,以保证Heritrix能够先行处理HTTP协议中的相关内容。与加入自己定制的Extractor的过程类似,开发者们也可以定制其他几种处理器。同样,只需要在modules目录下找到相应的.options文件,然后将类全名加入即可。
首先提出两个问题:
l 什么是Queue-assignment-policy
l 为什么要改变Queue-assignment-policy
在10.2节中,向读者介绍过了Heritrix的架构。其中,讲解了Heritrix使用了Berkeley DB来构建链接队列。这些队列被置放于BdbMultipleWorkQueues中时,总是先给予一个Key,然后将那些Key值相同的链接放在一起,成为一个队列,也就是一个Queue。
这里就出现了一个问题,这个Key值到底该如何计算呢?事实上,这里也说的Key值,应该是做为一种标识符的形式存在。也就是说,它要与URL之间有一种内在的联系。
在Heritrix中,为每个队列赋上Key值的策略,也就是它的queue-assignment-policy。这就解答了第一个问题。
在默认的情况下,Heritrix使用HostnameQueueAssignmentPolicy来解决Key值生成的问题。仔细看一下这个策略的名称就知道,这种策略其实是以链接的Host名称为Key值来解决这个问题的。换句话也就是说,相同Host名称的所有URL都会被置放于同一个队列中间。
这种方式在很大程度上可以解决广域网中信息抓取时队列的键值问题。但是,它对于某个单独网站的网页抓取,就出现了很大的问题。以Sohu的新闻网页为例,其中大部分的URL都来自于sohu网站的内部,因此,如果使用了HostnameQueueAssignmentPolicy,则会造成有一个队列的长度非常长的情况。
在Heritrix中,一个线程从一个队列中取URL链接时,总是会先从队列的头部取出第一个链接,在这之后,这个被取出链接的队列会进入阻塞状态,直到待该链接处理完,它才会从阻塞状态中恢复。
假如使用HostnameQueueAssignmentPolicy策略来应对抓取一个网站中内容的情况,很有可能造成仅有一个线程在工作,而其他所有线程都在等待。这是因为那个装有绝大多数URL链接的队列几乎会永远处于阻塞状态,因此,别的线程根本获取不到其中的URI,在这种情况下,抓取工作会进入一种类似于休眠的状态。因此,需要改变queue-assignment-policy来避免发生这种情况,这也就回答了第二个问题。
那么,被改变的Key值的生成方式,应该具有什么样的要求呢?从上面的叙述中可以知道,这个Key值最重要的一点就是应该能够有效的将所有的URL散列到不同的队列中,最终能使所有的队列的长度的方差较小,在这种情况下,才能保证工作线程的最大效率。
任何扩展queue-assignment-policy的默认实现的类,均继承自QueueAssignmentPolicy并覆写了其getClassKey()方法,getClassKey方法的参数为一个链接对象,而我们的散列算法,正是要根据这个链接对象来返回一个值。
具体的算法就不说了,有许多种方法可以实现的。比如使用字符串的长度等,在百度上搜索URL散列算法,最为出名的就要算是ELFHash法了。关于它的实现,有兴趣的读者可以自行研究。
FrontierScheduler是一个PostProcessor,它的作用是将在Extractor中所分析得出的链接加入到Frontier中,以待继续处理。先来看一下FrontierScheduler的innerProcess()方法,代码如下。
代码10.12
protected void innerProcess(final CrawlURI curi) {
if (LOGGER.isLoggable(Level.FINEST)) {
LOGGER.finest(getName() + " processing " + curi);
}
// 如果当前链接的处理结果中,有一些高优
// 先级的链接要被处理
if (curi.hasPrerequisiteUri() && curi.getFetchStatus() == S_DEFERRED) {
handlePrerequisites(curi);
return;
}
// 对当前这个Processor进行同步
synchronized(this) {
// 从处理结果中,取出所有链接进行循环
for (final Iterator iter = curi.getOutLinks().iterator();
iter.hasNext();) {
Object obj = iter.next();
CandidateURI cauri = null;
// 转型为CandidateURI
if (obj instanceof CandidateURI) {
cauri = (CandidateURI)obj;
} else {
LOGGER.severe("Unexpected type: " + obj);
}
// 调用schedule()方法
if (cauri != null) {
schedule(cauri);
}
}
}
}
protected void schedule(CandidateURI caUri) {
// 调用Frontier中的schedule()方法
// 将传入的链接加入到等待队列中
getController().getFrontier().schedule(caUri);
}
上面的代码中,首先检查当前链接处理后的结果集中是否有一些属于高优先级的链接,如果是,则立刻转走进行处理。如果没有,则对所有的结果集进行遍历,然后调用Frontier中的schedule方法加入队列进行处理。
在这里,innerProcess()中并未立刻使用Frontier中的schedule()方法,而是增加了一层封装,先调用了一个类内部的protected类型的schedule()方法,进而在这个方法中再调用Frontier的schedule方法。这种方式对FrontierScheduler进行扩展留出了很好的接口。
例如,当有某个任务在抓取时,可能希望人为的去除符合某种条件的URL链接,使得其内容不会保存到本地。比如,要去除所有的扩展名为.zip、.exe、.rar、.pdf和.doc的链接(其实也就是不想下载这类文件)。可以通过继承FrontierScheduler,并重写内部的schedule方法来达到我们的需要。以下是一个示例。
protected void schedule(CandidateURI caUri) {
String url = caUri.toString();
if (url.endsWith(".zip")
|| url.endsWith(".rar")
|| url.endsWith(".exe")
|| url.endsWith(".pdf")
|| url.endsWith(".doc")
|| url.endsWith(".xls")) {
return;
}
getController().getFrontier().schedule(caUri);
}
这样,每当Heritrix在执行任务时,遇到这样的文件,就会跳过抓取,从而达到了对URL链接进行筛选的目的。
Robots.txt是一种专门用于搜索引擎网络爬虫的文件,当构造一个网站时,如果作者希望该网站的内容被搜索引擎收录,就可以在网站中创建一个纯文本文件robots.txt,在这个文件中,声明该网站不想被robot访问的部分。这样,该网站的部分或全部内容就可以不被搜索引擎收录了,或者指定搜索引擎只收录指定的内容。
Heritrix在其说明文档中,表明它是一个完全遵守robots.txt协议的网络爬虫。这一点固然在宣传上起到了一定的作用。但是,在实际的网页采集过程中,这并不是一种最好的作法。因为大部分的网站并不会放置一个robots.txt文件以供搜索引擎读取,在互联网信息以几何级数增长的今天,网站总是在希望自己的内容不被人所利用的同时,又希望自己能够被更多的用户从搜索引擎上检索到。
不过幸好,robots.txt协议本身只是一种附加的协议,网站本身并不能了解究竟哪些Socket联接属于爬虫哪些属于正常的浏览器连接。所以,不遵守robos.txt协议成为了更多搜索引擎的首选。
使用过Heritrix的朋友就会发现这样一个问题,如果当一个网站没有放置robots.txt文件时,Heritrix总是要花上大量的时间试图去访问这样一个文件,甚至可能retry很多次。这无疑很大的降低了抓取效率。因此,为了提高抓取的效率,可以试着将对robots.txt的访问部分去除。
在Heritrix中,对robots.txt文件的处理是处于PreconditionEnforcer这个Processor中的。PreconditionEnforcer是一个Prefetcher,当处理时,总是需要考虑一下当前这个链接是否有什么先决条件要先被满足的,而对robots.txt的访问则正好是其中之一。在PreconditionEnforcer中,有一个private类型的方法,它的方法签名为:
private boolean considerRobotsPreconditions(CrawlURI curi)
该方法的含义为:在进行对参数所表示的链接的抓取前,看一下是否存在一个由robots.txt所决定的先决条件。很显然,如果对每个链接都有这样的处理。那么,很有可能导致整个抓取任务的失败。因此,需要对它进行调整。
这个方法返回true时的含义为需要考虑robots.txt文件,返回false时则表示不需要考虑robots.txt文件,可以继续将链接传递给后面的处理器。所以,最简单的修改办法就是将这个方法整个注释掉,只留下一个false的返回值。经过笔者的试验,这种方法完全可行,抓取的速度提高了至少一半以上!