最近在学习Java爬虫,发现了webmagic轻量级框架,在网上搜索了一些教程,然后自己尝试着写了一个对菜鸟教程的爬虫,主要功能为把教程内容html转换为markdown文本,方便离线阅读;
做这个工具的主要原因是,我们单位的工作环境一般要求断网,菜鸟教程上的教学作为入门一般不错,为了方便离线学习,做了这个应用;现在写了主要为了分享和自己学习总结;
第一次写博文,不完善的地方请见谅
关于 **WebMagic**,我就不作介绍了,主页传送门 -> WebMagic
Maven依赖
<dependency>
<groupId>us.codecraft</groupId>
<artifactId>webmagic-core</artifactId>
<version>0.7.1</version>
</dependency>
<dependency>
<groupId>us.codecraft</groupId>
<artifactId>webmagic-extension</artifactId>
<version>0.7.1</version>
</dependency>
中文文档 -> http://webmagic.io/docs/zh/
因为用到了lambda表达式,jdk版本要求1.8+,IDE使用IDEA
----
写个介绍真辛苦,下面进入项目
----
项目创建
-
创建项目,导入jar包(略)
-
主要内容结构如图
Controller - 控制器,Main方法入口
MarkdownSavePipeline - 持久化组件-保存为文件
RunoobPageProcessor - 页面解析组件
Service - 服务提供组件,相当于Utils,主要用于包装通用方法
菜鸟教程页面
这里选取的是Scala教程作为样板
开始上代码
import us.codecraft.webmagic.Spider;
/**
* 爬虫控制器,main方法入口
* Created by bekey on 2017/6/6.
*/
public class Controller {
public static void main(String[] args) {
// String url = "http://www.runoob.com/regexp/regexp-tutorial.html";
String url = "http://www.runoob.com/scala/scala-tutorial.html";
//爬虫控制器 添加页面解析 添加url(request) 添加持久化组件 创建线程 执行
Spider.create(new RunoobPageProcessor()).addUrl(url).addPipeline(new MarkdownSavePipeline()).thread(1).run();
}
}
WebMagic 中主要有四大组件
- Downloader 负责下载页面
- PageProcessor 负责解析页面
- Scheduler 调度URL
- Pipeline 持久化到文件/数据库等
一般Downloader和Scheduler不需要定制
流程核心控制引擎 -- Spider ,用来自由配置爬虫,创建/启动/停止/多线程等
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import us.codecraft.webmagic.Page;
import us.codecraft.webmagic.Site;
import us.codecraft.webmagic.processor.PageProcessor;
import us.codecraft.webmagic.selector.Html;
/**
* 菜鸟教程markdown转换
* Created by bekey on 2017/6/6.
*/
public class RunoobPageProcessor implements PageProcessor{
private static String name = null;
private static String regex = null;
// 抓取网站的相关配置,包括编码、重试次数、抓取间隔、超时时间、请求消息头、UA信息等
private Site site= Site.me().setRetryTimes(3).setSleepTime(1000).setTimeOut(3000).addHeader("Accept-Encoding", "/")
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.59 Safari/537.36");
@Override
public Site getSite() {
return site;
}
@Override
//此处为处理函数
public void process(Page page) {
Html html = page.getHtml();
// String name = page.getUrl().toString().substring();
if(name == null ||regex == null){
String url = page.getRequest().getUrl();
name = url.substring(url.lastIndexOf('/',url.lastIndexOf('/')-1)+1,url.lastIndexOf('/'));
regex = "http://www.runoob.com/"+name+"/.*";
}
//添加访问
page.addTargetRequests(html.links().regex(regex).all());
//获取文章主内容
Document doc = html.getDocument();
Element article = doc.getElementById("content");
//获取markdown文本
String document = Service.markdown(article);
//处理保存操作
String fileName = article.getElementsByTag("h1").get(0).text().replace("/","").replace("\\","") + ".md";
page.putField("fileName",fileName);
page.putField("content",document);
page.putField("dir",name);
}
}
一般爬虫最重要的就是解析,所以必须创建解析器实现PageProcessor接口,
PageProcessor接口有两个方法
- public Site getSite() Site 抓取网站的配置,一般可以设为静态属性
- public void process(Page page) 页面处理函数 , 其中Page 代表了从Downloader下载到的一个页面——可能是HTML,也可能是JSON或者其他文本格式的内容。
属性设置有很多,可以自己尝试,当然抓取间隔不要太短,否则会给目标网站带来很大负担,特别注意
addHeader -- 添加消息头;最基本的反反爬虫手段;
Html html = page.getHtml();
// String name = page.getUrl().toString().substring();
if(name == null ||regex == null){
String url = page.getRequest().getUrl();
name = url.substring(url.lastIndexOf('/',url.lastIndexOf('/')-1)+1,url.lastIndexOf('/'));
regex = "http://www.runoob.com/"+name+"/.*";
}
//添加访问
page.addTargetRequests(html.links().regex(regex).all());
这段,主要是链接处理;在Controller中,Spider一般有一个入口request,但是不是每发送一个请求就要创建一个Spider(否则要多线程干什么囧);
通过page.addTargetRequests 及其他重载方法可以很轻松地添加请求,请求会放进Scheduler并去重,根据Sleeptime间隔时间访问
links() 方法是Selectable接口的抽象方法,可以提取页面上的链接,因为是要爬取整个教程,所以用正则提取正确的链接,放入Scheduler;
Selectable 相关的抽取元素链式API是WebMagic的一个核心功能。使用Selectable接口,可以直接完成页面元素的链式抽取,也无需去关心抽取的细节。 主要提供 xpath(Xpath选择器) / $(css选择器) / regex(正则抽取) /replace(替换)/links(获取链接) 等方法,不过我不太会用,所以后面页面解析主要还是使用Jsoup实现
WebMagic PageProcessor 中解析页面主要就是使用Jsoup实现的,Jsoup是一款优秀的页面解析器,具体使用请看官方文档 http://www.open-open.com/jsoup/
//获取文章主内容
Document doc = html.getDocument();
Element article = doc.getElementById("content");
page 和 jsoup的转换 通过getDocument实现,这里的Document类,import org.jsoup.nodes.Document
通过页面结构,我们可以很轻易地发现,教程主要内容都藏在id为content的div里,拿出来
//获取markdown文本
String document = Service.markdown(article);
通过静态方法拿到markdown文本,看一下具体实现,Service类
/**
* 公有方法,将body解析为markdown文本
* @param article #content内容
* @return markdown文本
*/
public static String markdown(Element article){
StringBuilder markdown = new StringBuilder("");
article.children().forEach(it ->parseEle(markdown, it, 0));
return markdown.toString();
}
/**
* 私有方法,解析单个元素并向StringBuilder添加
*/
private static void parseEle(StringBuilder markdown,Element ele,int level){
//处理相对地址为绝对地址
ele.getElementsByTag("a").forEach(it -> it.attr("href",it.absUrl("href")));
ele.getElementsByTag("img").forEach(it -> it.attr("src",it.absUrl("src")));
//先判断class,再判定nodeName
String className = ele.className();
if(className.contains("example_code")){
String code = ele.html().replace(" "," ").replace("<br>","");
markdown.append("```\n").append(code).append("\n```\n");
return;
}
String nodeName = ele.nodeName();
//获取到每个nodes,根据class和标签进行分类处理,转化为markdown文档
if(nodeName.startsWith("h") && !nodeName.equals("hr")){
int repeat = Integer.parseInt(nodeName.substring(1)) + level;
markdown.append(repeat("#", repeat)).append(' ').append(ele.text());
}else if(nodeName.equals("p")){
markdown.append(ele.html()).append(" ");
}else if(nodeName.equals("div")){
ele.children().forEach(it -> parseEle(markdown, it, level + 1));
}else if(nodeName.equals("img")) {
ele.removeAttr("class").removeAttr("alt");
markdown.append(ele.toString()).append(" ");
}else if(nodeName.equals("pre")){
markdown.append("```").append("\n").append(ele.html()).append("\n```");
}else if(nodeName.equals("ul")) {
markdown.append("\n");
ele.children().forEach(it -> parseEle(markdown, it, level + 1));
}else if(nodeName.equals("li")) {
markdown.append("* ").append(ele.html());
}
markdown.append("\n");
}
private static String repeat(String chars,int repeat){
String a = "";
if(repeat > 6) repeat = 6;
for(int i = 0;i<=repeat;i++){
a += chars;
}
return a;
}
不得不说,java8的lambda表达式太好使了,让java竟然有了脚本的感觉(虽然其他很多语言已经实现很久了)
这里是具体的业务实现,没有什么好特别讲解的,就是根据规则一点点做苦力;我这里主要依靠class 和 nodeName 把html转为markdown,处理得不算很完善吧,具体实现可以慢慢改进~
需要注意的是,这里的Element对象,都是来自于Jsoup框架,使用起来很有JavaScript的感觉,如果你常使用js,对这些方法名应该都挺了解的,就不详细讲了;如果Element里属性有连接,通过absUrl(String attrName)可以很方便得获得绝对链接地址;
回到process函数
//处理保存操作
String fileName = article.getElementsByTag("h1").get(0).text().replace("/","").replace("\\","") + ".md";
page.putField("fileName",fileName);
page.putField("content",document);
page.putField("dir",name);
再得到文本后,我们就可以对文本进行持久化处理;事实上,我们可以不借助Pieline组件进行持久化,但是基于模块分离,以及更好的复用/扩展,实现一个持久化组件也是有必要的(假如你不仅仅需要一个爬虫)
这里,page.putField 方法,实际上是讲内容放入一个 ResultItems 的Map组件中,它负责保存PageProcessor处理的结果,供Pipeline使用. 它的API与Map很类似,但包装了其他一些有用的信息,值得注意的是它有一个字段,skip,page中可以通过page.setSkip(true)方法,使得页面不必持久化
/**
* 保存文件功能
* Created by bekey on 2017/6/6.
*/
public class MarkdownSavePipeline implements Pipeline {
@Override
public void process(ResultItems resultItems, Task task) {
try {
String fileName = resultItems.get("fileName");
String document = resultItems.get("content");
String dir = resultItems.get("dir");
Service.saveFile(document,fileName,dir);
}catch (IOException e){
e.printStackTrace();
}
}
}
Pipeline接口,同样要实现一个
public void process(ResultItems resultItems, Task task) 方法,处理持久化操作
ResultItems 已经介绍过了,里面除了有你page中保存的内容外,还提供了getRequest()方法,获取本次操作的Request对象,和一个getAll()的封装方法,给你迭代;
Task对象提供了两个方法
- getSite()
- getUUID()
没有使用过,但是看方法名大概能知道是做什么的;
Serivice.saveFile 是我自己简单封装的保存文件方法,在src同级目录创建以教程命名的文件夹,以每个页面标题为文件名创建.md文件.简单的IO操作,就不贴出来;
特别注意的是WebMagic框架会在底层catch异常,但是却不会报错,所以开发调试的时候,如果要捕获异常的话,需要自己try catch ,特别是那些RuntimeException
啰啰嗦嗦打了好多,完整代码下载,我的GitHub
https://github.com/BekeyChao/HelloWorld/tree/master/src
因为没有用git管理(主要有一些其他内容),所以是手动同步的,如果运行不起来,就好好研究吧~