前段时间做了个新闻类的爬虫,用到了WebCollector框架(项目地址:wc).我用的是1.x版本,就在前不久作者更新了功能更加强大的2.x版本,有兴趣的可以去研究一下.
一般来说新闻爬虫是最简单的爬虫,一是新闻的保存比较有规律,直接体现到url上面: domain.栏目.日期.newsId.html. 这种格式,爬虫在抓取过程中可以非常方便的提取目标url. 二是新闻内容相对静态无需页面操作即可获取全部内容(也就是只需获取页面源代码而不用js生成后的结果).
下面我结合我的业务及使用时碰到并解决的一些问题,对WebCollector框架做个简要的分析,便于新手学习.
1.x版本中有文件与redis两种模式,它们抓取逻辑都是完全一样的.只是存取抓取信息一个是在磁盘文件一个是在redis服务器.
抓取信息记录哪些未爬取已爬取等信息.文件模式这些信息保存在crawl目录
编写一个简单的wc爬虫非常方便, 只需继承BreadthCrawler重写visit方法.官网例子:
public class ZhihuCrawler extends BreadthCrawler{
@Override
public void visit(Page page) {
String question_regex="^http://www.zhihu.com/question/[0-9]+";
if(Pattern.matches(question_regex, page.getUrl())){
System.out.println("processing "+page.getUrl());
/*extract title of the page*/
String title=page.getDoc().title();
System.out.println(title);
/*extract the content of question*/
String question=page.getDoc().select("div[id=zh-question-detail]").text();
System.out.println(question);
}
}
public static void main(String[] args) throws IOException{
ZhihuCrawler crawler=new ZhihuCrawler();
crawler.addSeed("http://www.zhihu.com/question/21003086");
crawler.addRegex("http://www.zhihu.com/.*");
crawler.start(5);
}
}
visit方法作用是:在整个抓取过程中,只要抓到一个符合的页面,wc都会回调该方法,并传入一个包含了所有页面信息的page对象.
addSeed添加种子,种子链接会在爬虫启动之前加入到上面所说的抓取信息中并标记为未抓取状态.这个过程称为注入.
addRegex参数为一个url正则表达式, 过滤不必抓取的链接比如.js .jpg .css等,或者指定抓取链接的规则. 比如我使用时有个正则为:
http://news.hexun.com/2015-01-16/[0-9]+.html, 那么我的爬虫则只会抓取http://news.hexun.com/2015-01-16/172431075.html,http://news.hexun.com/2015-01-16/172429627.html 等news.hexun.com域名下2015-01-16日期的.html结尾的链接, 这种模式是和讯网所有新闻的模式,不同只是二级域名,日期和[0-9]+(这个大概是一个id).
start启动爬虫,入参5表示抓取5层,1.x版本基于广度,这个5层是这么理解: 当只添加了一个种子, 抓这个种子链接为第1层, 解析种子链接页面跟据正则过滤想要的链接保存至待抓取记录. 那么第2层就是抓取1层保存的记录并解析保存新记录,依次类推.
上例中添加regex抓取所有链接(.*), 在visit中使用question_regex过滤知乎问题页面打印.
其实上例可以通过 crawler.addRegex("http://www.zhihu.com/question/[0-9]+"); 这样visit中就不必再判断, 因为只抓了问题链接.
广度遍历爬虫的基类Crawler,定义了爬虫的全局属性. 抓取消息回调Handler实现. 定义了爬虫必要组件创建的抽象方法.
通用组件如Http请求Request, 页面解析HtmlParser, 抓取器Fetcher由CommonCrawler实现,
非通用组件Generator,DbUpdater,Injector分别有BreadthCrawler和RedisCrawler两个实现.
resumable:是否断点抓取, 默认为false, 非断点模式每次启动时会清空crawl目录
threads:Fetcher线程池的线程个数,理论上线程越多抓取速度越快.
start后首先将seeds集合中保存的链接注入到抓取信息.然后创建必要组件启动抓取.
Fetcher内部采用生成者消费者模式,QueueFeeder线程不断从Generator中获取抓取任务,FetcherThread则负责消费这些任务-抓取页面.
Generator的抓取任务生成方法next(); 采取了装饰者模式,有IntervalFilter,URLRegexFilter等不同规则的实现,最底层为FSGenerator负责从文件读取已保存的信息.
FetcherThread从队列中取到抓取任务后创建Request进行Http请求,
1.请求失败则调用Handler中定义的失败回调方法
2.请求成功则交由Parser解析html页面,返回ParseResult
ParseResult保存了解析到的页面链接, 文本, 锚标题等信息. Parser解析过程中会使用crawler.addRegex添加的正则过滤页面的url.
ParseResult生成后由DbUpdater负责将信息写入抓取信息.
然后调用Handler中定义的成功回调方法,也就crawler中定义的visit方法.
整个fetch过程阻塞,直到FetcherThread个数为0. 当单层抓取完成后, 会进行merge操作,将所有已抓取的及parser解析到的链接合并用于下层或者下次抓取.
因为我抓取新闻,本身业务简单,量也不大, 使用过程中遇到的问题也不是很多,就是没有注释一点点看代码累.
但wc http请求有个bug, 没有处理http响应为gzip格式的数据. 因为默认发起请求时没有指定Accept-Encoding为gzip,所以大部分网站不会返回gzip数据,所以大部分情况是正常的,而我在测试期间发现有些网站即使不指定,返回的仍然是gzip格式数据, 百度了一下才发现是次类网站不规范. 规范是指定指定了Accept-Encoding格式,服务器才返回对应格式的数据.
总之对于上述不规范的网站wc会产生乱码,无法正确解析html结果. 解决办法是通过重写createRequest方法,返回自定义的HttpRequest:
InputStream is = con.getInputStream();
String ce = con.getHeaderField("Content-Encoding");
if (ce != null && ce.equalsIgnoreCase("gzip")) {
//解压gzip格式数据
is = new GZIPInputStream(is);
}
is = new BufferedInputStream(is);
我这里加入了对响应头Content-Encoding的判断,如果是gzip格式则先解压数据.
用户也可以通过重写createParser方法返回自定义的HtmlParser, 重写html解析规则及结果. 我重写的HtmlParser, getParse方法还是使用super.getParse实现, 只是覆盖调用了默认ParseResult中的一些没用的数据来避免本地文件过大.
wc 支持设置代理, CommonCrawler中提供了setProxy方法设置http代理, 这个一般防止反爬虫封ip.
下面是我的获取可用代理ip类:
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.jsoup.Jsoup;
import org.jsoup.helper.HttpConnection.KeyVal;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import xxx.GzipUtil;
/**
* 代理ip抓取类
*
* @author xxx
* 2014年12月22日 下午3:12:07
* @version V1.0
*/
public class ProxyIpFetcher {
private static Logger LOGGER = LoggerFactory.getLogger(ProxyIpFetcher.class);
private static ExecutorService threadPool = Executors.newFixedThreadPool(15);
/**
*
* @author xxx
* 2014年12月24日 下午1:41:51
* @version V1.0
*/
private static class ValidateCallable implements Callable<KeyVal> {
String ip;
String port;
String targetUrl;
String[] keyWords;
public ValidateCallable(String ip, String port, String targetUrl, String[] keyWords) {
this.ip = ip;
this.port = port;
this.targetUrl = targetUrl;
this.keyWords = keyWords;
}
@Override
public KeyVal call() throws Exception {
//使用代理请求页面源代码
String htmlStr = GzipUtil.proxyGet(targetUrl, ip, port);
Document document = Jsoup.parse(htmlStr);
Elements kwMetas = document.head().select("meta[name=keywords]");
if (kwMetas != null && !kwMetas.isEmpty()) {
Element kwMate = kwMetas.first();
String kwCont = kwMate.attr("content");
boolean contains = false;
for (String kw : keyWords) {
if (contains = kwCont.contains(kw)) {
break ;
}
}
if (contains) {
//包含关键时认为此代理可用
return KeyVal.create(ip, port);
}
}
return null;
}
}
/**
* 获取一个可用的代理ip
* @param targetUrl 代理要访问的网站
* @param keyWords 目标页面包含的关键字(用以验证代理确实请求到目标页面)
* @return ip port pair
*/
public static KeyVal fetchOne(final String targetUrl, final String[] keyWords) {
String dlWebUrl = "http://www.kuaidaili.com/free/inha/1";
Document doc = null;
try {
doc = Jsoup.connect(dlWebUrl).timeout(5000).get();
} catch (Exception e) {
LOGGER.error("connet {} fail", dlWebUrl);
return null;
}
Element listDiv = doc.getElementById("list");
Elements trs = listDiv.select("table tbody tr");
List<Future<KeyVal>> futureList = new ArrayList<Future<KeyVal>>(trs.size());
for (final Element tr : trs) {
String ip = tr.child(0).text();
String port = tr.child(1).text();
futureList.add(threadPool.submit(new ValidateCallable(ip, port,
targetUrl, keyWords)));
}
for (int i = 0; i < 3; i++) {
Thread.yield();//尝试启动验证线程
}
for (Future<KeyVal> future : futureList) {
try {
KeyVal kv = future.get();
if (kv == null) {
//继续验证,直到获取一个可用的代理
continue ;
}
return kv;
} catch (InterruptedException e) {
LOGGER.error(e.getMessage());
//i don't what happened, so let it go.
} catch (ExecutionException e) {
//call执行异常, 记录日志
LOGGER.error(e.getMessage());
}
}
return null;
}
}
代码利用jsoup解析快代理, 拿到一系列代理ip然后用代理ip请求目标页面,如果能返回指定的keywords则认为代理可用, 一般验证代理ip的方法都是去请求百度,有返回则认为代理可用,可我在测试过程中发现,有些代理ip会请求到一些广告页面,不能简单的根据response code 或则不抛异常判断代理可用.对于一些没有meta keywords的页面, 可修改ValidateCallable,从其它特征判断请求到了正确的页面.
wc还有个bug: 种子已经注入,可愣是等到超时abort,都没有读出种子,导致抓取结束.这个bug发生概率极低, 我是在大篇幅的日志中偶然发现,无法再次重现. 贴图为证: