最近在学习Java爬虫,发现了webmagic轻量级框架,在网上搜索了一些教程,然后自己尝试着写了一个对的爬虫,主要功能为把教程内容html转换为markdown文本,方便离线阅读;
做这个工具的主要原因是,我们单位的工作环境一般要求断网,菜鸟教程上的教学作为入门一般不错,为了方便离线学习,做了这个应用;现在写了主要为了分享和自己学习总结; 第一次写博文,不完善的地方请见谅关于 **WebMagic**,我就不作介绍了,主页传送门 ->
Maven依赖us.codecraft webmagic-core 0.7.1 us.codecraft webmagic-extension 0.7.1
中文文档 ->
因为用到了lambda表达式,jdk版本要求1.8+,IDE使用IDEA----
写个介绍真辛苦,下面进入项目---- 项目创建
-
创建项目,导入jar包(略)
-
主要内容结构如图
Controller - 控制器,Main方法入口
MarkdownSavePipeline - 持久化组件-保存为文件
RunoobPageProcessor - 页面解析组件
Service - 服务提供组件,相当于Utils,主要用于包装通用方法
菜鸟教程页面
这里选取的是作为样板
开始上代码
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(获取链接) 等方法,不过我不太会用,所以后面页面解析主要还是使用实现
WebMagic PageProcessor 中解析页面主要就是使用Jsoup实现的,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("",""); 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
因为没有用git管理(主要有一些其他内容),所以是手动同步的,如果运行不起来,就好好研究吧~