详细介绍如何自研一款"博客搬家"功能

前言   现在的技术博客(社区)越来越多,比如:imooc、spring4All、csdn、cnblogs或者iteye等,有很多朋友可能在这些网站上都发表过博文,当有一天我们想自己搞一个博客网站时就会发现好多东西已经写过了,我们不可能再重新写一遍,况且多个平台上都有自己发表的文章,也不可能挨个去各个平台ctrl c + ctrl v。鉴于此, 我在我的开源博客里新开发了一个“博客迁移”的功能,目前支持imooc、csdn、iteye和cnblogs,后期会适配更多站点。 功能介绍   如下视频所示:   抓取展示: 功能特点   使用方便,抓取规则已内置,只需修改很少的配置就可运行。支持同步抓取文章标签、description和keywords,支持转存图片文件。使用开源的国产爬虫框架webMagic,方便扩展爬虫功能。 使用教程   目前,该功能已内置了以下几个平台(imooc、csdn、cnblogs和iteye),根据不同的平台,程序已默认了一套抓取规则,如下图系列   cnblogs抓取规则:   使用时,只需要手动指定以下几项配置即可   其他信息在选择完博文平台后,程序会自动补充完整。圈中必填的几项配置如下: 选择博文平台:选择待操作的博文平台(程序会自动生成对应平台的抓取规则) 自动转存图片:勾选时默认将文章中的图片转存到七牛云中(需提前配置七牛云) 文章分类:是指抓取的文章保存到本地数据库中的文章分类 用户ID:是指各平台中,登陆完成后的用户ID,程序中已给出了对应获取的方法 文章总页数:是指待抓取的用户所有文章的页数 Cookie(非必填):只在必须需要登陆才能获取数据时指定,获取方式如程序中所示   在指定完博文平台、用户ID和文章总页数后,爬虫的其他配置项就会自动补充完整,最后直接执行该程序即可。 注意:默认同步过来的文章为“草稿”状态,主要是为了防止抓取的内容错误,而直接显示到网站前台,造成不必要的麻烦。所以,需要手动确认无误后修改发布状态。另外,针对一些做了防盗链的网站,我们在使用“文章搬运工”时,还要勾选上“自动转存图片”,至于为何要这么做,在下面会有解释。 关于“文章搬运工”功能的实现    “文章搬运工”功能听起来觉得高大上,类似的比如CSDN和cnblogs里的“博客搬家”功能,其实实现起来很简单。下面听我道一道,你也可以轻松做出一个“博客搬家”功能!   “博客搬家”首先需要克服的问题无非就是:怎么从别人的页面中提取出相关的文章信息后保存到自己的服务器中。说到页面提取,可能很多同学不约而同的就想到了:爬虫!没错,就是通过最基础的网络爬虫就可实现,而OneBlog的文章搬运工功能就是基于爬虫实现的。   OneBlog中选用了国产的优秀的开源爬虫框架:webMagic。   WebMagic是一个简单灵活的Java爬虫框架。之所以选择该框架,完全依赖于它的优秀特性: 完全模块化的设计,强大的可扩展性。 核心简单但是涵盖爬虫的全部流程,灵活而强大,也是学习爬虫入门的好材料。 提供丰富的抽取页面API。 无配置,但是可通过POJO+注解形式实现一个爬虫。 支持多线程。 支持分布式。 支持爬取js动态渲染的页面。 无框架依赖,可以灵活的嵌入到项目中去   关于webMagic的其他详细介绍,请去webMagic的官网查阅,本文不做赘述。   下面针对OneBlog中的“文章搬运工”功能做一下简单的分析。   第一步,添加依赖包 复制代码 1 2 us.codecraft 3 webmagic-core 4 0.7.3 5 6 7 org.slf4j 8 slf4j-log4j12 9 10 11 12 13 us.codecraft 14 webmagic-extension 15 0.7.3 16 17 18 org.slf4j 19 slf4j-log4j12 20 21 22 复制代码   第二步,抽取爬虫规则 为了方便扩展,我们要抽象出webMagic爬虫运行时需要的基本属性到BaseModel.java 复制代码 1 /** 2 * @author yadong.zhang (yadong.zhang0415(a)gmail.com) 3 * @website https://www.zhyd.me 4 * @version 1.0 5 * @date 2018/7/23 13:33 6 */ 7 @Data 8 public class BaseModel { 9 @NotEmpty(message = "必须指定标题抓取规则(xpath)") 10 private String titleRegex; 11 @NotEmpty(message = "必须指定内容抓取规则(xpath)") 12 private String contentRegex; 13 @NotEmpty(message = "必须指定发布日期抓取规则(xpath)") 14 private String releaseDateRegex; 15 @NotEmpty(message = "必须指定作者抓取规则(xpath)") 16 private String authorRegex; 17 @NotEmpty(message = "必须指定待抓取的url抓取规则(xpath)") 18 private String targetLinksRegex; 19 private String tagRegex; 20 private String keywordsRegex = "//meta [@name=keywords]/@content"; 21 private String descriptionRegex = "//meta [@name=description]/@content"; 22 @NotEmpty(message = "必须指定网站根域名") 23 private String domain; 24 private String charset = "utf8"; 25 26 /** 27 * 每次爬取页面时的等待时间 28 */ 29 @Max(value = 5000, message = "线程间隔时间最大只能指定为5000毫秒") 30 @Min(value = 1000, message = "线程间隔时间最小只能指定为1000毫秒") 31 private int sleepTime = 1000; 32 33 /** 34 * 抓取失败时重试的次数 35 */ 36 @Max(value = 5, message = "抓取失败时最多只能重试5次") 37 @Min(value = 1, message = "抓取失败时最少只能重试1次") 38 private int retryTimes = 2; 39 40 /** 41 * 线程个数 42 */ 43 @Max(value = 5, message = "最多只能开启5个线程(线程数量越多越耗性能)") 44 @Min(value = 1, message = "至少要开启1个线程") 45 private int threadCount = 1; 46 47 /** 48 * 抓取入口地址 49 */ 50 // @NotEmpty(message = "必须指定待抓取的网址") 51 private String[] entryUrls; 52 53 /** 54 * 退出方式{1:等待时间(waitTime必填),2:抓取到的url数量(urlCount必填)} 55 */ 56 private int exitWay = 1; 57 /** 58 * 单位:秒 59 */ 60 private int waitTime = 60; 61 private int urlCount = 100; 62 63 private List cookies = new ArrayList<>(); 64 private Map headers = new HashMap<>(); 65 private String ua = "Mozilla/5.0 (ozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36"; 66 67 private String uid; 68 private Integer totalPage; 69 70 /* 保留字段,针对ajax渲染的页面 */ 71 private Boolean ajaxRequest = false; 72 /* 是否转存图片 */ 73 private boolean convertImg = false; 74 75 public String getUid() { 76 return uid; 77 } 78 79 public BaseModel setUid(String uid) { 80 this.uid = uid; 81 return this; 82 } 83 84 public Integer getTotalPage() { 85 return totalPage; 86 } 87 88 public BaseModel setTotalPage(Integer totalPage) { 89 this.totalPage = totalPage; 90 return this; 91 } 92 93 public BaseModel setTitleRegex(String titleRegex) { 94 this.titleRegex = titleRegex; 95 return this; 96 } 97 98 public BaseModel setContentRegex(String contentRegex) { 99 this.contentRegex = contentRegex; 100 return this; 101 } 102 103 public BaseModel setReleaseDateRegex(String releaseDateRegex) { 104 this.releaseDateRegex = releaseDateRegex; 105 return this; 106 } 107 108 public BaseModel setAuthorRegex(String authorRegex) { 109 this.authorRegex = authorRegex; 110 return this; 111 } 112 113 public BaseModel setTargetLinksRegex(String targetLinksRegex) { 114 this.targetLinksRegex = targetLinksRegex; 115 return this; 116 } 117 118 public BaseModel setTagRegex(String tagRegex) { 119 this.tagRegex = tagRegex; 120 return this; 121 } 122 123 public BaseModel setKeywordsRegex(String keywordsRegex) { 124 this.keywordsRegex = keywordsRegex; 125 return this; 126 } 127 128 public BaseModel setDescriptionRegex(String descriptionRegex) { 129 this.descriptionRegex = descriptionRegex; 130 return this; 131 } 132 133 public BaseModel setDomain(String domain) { 134 this.domain = domain; 135 return this; 136 } 137 138 public BaseModel setCharset(String charset) { 139 this.charset = charset; 140 return this; 141 } 142 143 public BaseModel setSleepTime(int sleepTime) { 144 this.sleepTime = sleepTime; 145 return this; 146 } 147 148 public BaseModel setRetryTimes(int retryTimes) { 149 this.retryTimes = retryTimes; 150 return this; 151 } 152 153 public BaseModel setThreadCount(int threadCount) { 154 this.threadCount = threadCount; 155 return this; 156 } 157 158 public BaseModel setEntryUrls(String[] entryUrls) { 159 this.entryUrls = entryUrls; 160 return this; 161 } 162 163 public BaseModel setEntryUrls(String entryUrls) { 164 if (StringUtils.isNotEmpty(entryUrls)) { 165 this.entryUrls = entryUrls.split("\r\n"); 166 } 167 return this; 168 } 169 170 public BaseModel setExitWay(int exitWay) { 171 this.exitWay = exitWay; 172 return this; 173 } 174 175 public BaseModel setWaitTime(int waitTime) { 176 this.waitTime = waitTime; 177 return this; 178 } 179 180 public BaseModel setHeader(String key, String value) { 181 Map headers = this.getHeaders(); 182 headers.put(key, value); 183 return this; 184 } 185 186 public BaseModel setHeader(String headersStr) { 187 if (StringUtils.isNotEmpty(headersStr)) { 188 String[] headerArr = headersStr.split("\r\n"); 189 for (String s : headerArr) { 190 String[] header = s.split("="); 191 setHeader(header[0], header[1]); 192 } 193 } 194 return this; 195 } 196 197 public BaseModel setCookie(String domain, String key, String value) { 198 List cookies = this.getCookies(); 199 cookies.add(new Cookie(domain, key, value)); 200 return this; 201 } 202 203 public BaseModel setCookie(String cookiesStr) { 204 if (StringUtils.isNotEmpty(cookiesStr)) { 205 List cookies = this.getCookies(); 206 String[] cookieArr = cookiesStr.split(";"); 207 for (String aCookieArr : cookieArr) { 208 String[] cookieNode = aCookieArr.split("="); 209 if (cookieNode.length <= 1) { 210 continue; 211 } 212 cookies.add(new Cookie(cookieNode[0].trim(), cookieNode[1].trim())); 213 } 214 } 215 return this; 216 } 217 218 public BaseModel setAjaxRequest(boolean ajaxRequest) { 219 this.ajaxRequest = ajaxRequest; 220 return this; 221 } 222 } 复制代码 如上方代码中所示,我们抽取出了基本的抓取规则和针对不同平台设置的网站属性(domain、cookies和headers等)。   第三步,编写解析器 因为“博客迁移功能”目前只涉及到页面的解析、抽取,所以,我们只需要实现webMagic的PageProcessor接口即可。这里有个关键点需要注意:随着网络技术的发展,现在前后端分离的网站越来越多,而前后端分离的网站基本通过ajax渲染页面。这种情况下,httpClient获取到的页面内容只是js渲染前的html,因此按照常规的解析方式,是解析不到这部分内容的,因此我们需要针对普通的html页面和js渲染的页面分别提供解析器。本文主要讲解针对普通html的解析方式,至于针对js渲染的页面的解析,以后会另行写文介绍。 复制代码 1 /** 2 * 统一对页面进行解析处理 3 * 4 * @author yadong.zhang (yadong.zhang0415(a)gmail.com) 5 * @version 1.0 6 * @website https://www.zhyd.me 7 * @date 2018/7/31 17:37 8 */ 9 @Slf4j 10 public class BaseProcessor implements PageProcessor { 11 private static BaseModel model; 12 13 BaseProcessor() { 14 } 15 16 BaseProcessor(BaseModel m) { 17 model = m; 18 } 19 20 @Override 21 public void process(Page page) { 22 Processor processor = new HtmlProcessor(); 23 if (model.getAjaxRequest()) { 24 processor = new JsonProcessor(); 25 } 26 processor.process(page, model); 27 28 } 29 30 @Override 31 public Site getSite() { 32 Site site = Site.me() 33 .setCharset(model.getCharset()) 34 .setDomain(model.getDomain()) 35 .setSleepTime(model.getSleepTime()) 36 .setRetryTimes(model.getRetryTimes()); 37 38 //添加抓包获取的cookie信息 39 List cookies = model.getCookies(); 40 if (CollectionUtils.isNotEmpty(cookies)) { 41 for (Cookie cookie : cookies) { 42 if (StringUtils.isEmpty(cookie.getDomain())) { 43 site.addCookie(cookie.getName(), cookie.getValue()); 44 continue; 45 } 46 site.addCookie(cookie.getDomain(), cookie.getName(), cookie.getValue()); 47 } 48 } 49 //添加请求头,有些网站会根据请求头判断该请求是由浏览器发起还是由爬虫发起的 50 Map headers = model.getHeaders(); 51 if (MapUtils.isNotEmpty(headers)) { 52 Set> entrySet = headers.entrySet(); 53 for (Map.Entry entry : entrySet) { 54 site.addHeader(entry.getKey(), entry.getValue()); 55 } 56 } 57 return site; 58 } 59 } 复制代码 Processor.java接口,只提供一个process方法供实际的解析器实现 复制代码 1 /** 2 * 页面解析接口 3 * 4 * @author yadong.zhang (yadong.zhang0415(a)gmail.com) 5 * @version 1.0 6 * @website https://www.zhyd.me 7 * @date 2018/7/31 17:37 8 */ 9 public interface Processor { 10 void process(Page page, BaseModel model); 11 } 复制代码 HtmlProcessor.java 复制代码 1 /** 2 * 解析处理普通的Html网页 3 * 4 * @author yadong.zhang (yadong.zhang0415(a)gmail.com) 5 * @version 1.0 6 * @website https://www.zhyd.me 7 * @date 2018/7/31 17:37 8 */ 9 public class HtmlProcessor implements Processor { 10 11 @Override 12 public void process(Page page, BaseModel model) { 13 Html pageHtml = page.getHtml(); 14 String title = pageHtml.xpath(model.getTitleRegex()).get(); 15 String source = page.getRequest().getUrl(); 16 if (!StringUtils.isEmpty(title) && !"null".equals(title) && !Arrays.asList(model.getEntryUrls()).contains(source)) { 17 page.putField("title", title); 18 page.putField("source", source); 19 page.putField("releaseDate", pageHtml.xpath(model.getReleaseDateRegex()).get()); 20 page.putField("author", pageHtml.xpath(model.getAuthorRegex()).get()); 21 page.putField("content", pageHtml.xpath(model.getContentRegex()).get()); 22 page.putField("tags", pageHtml.xpath(model.getTagRegex()).all()); 23 page.putField("description", pageHtml.xpath(model.getDescriptionRegex()).get()); 24 page.putField("keywords", pageHtml.xpath(model.getKeywordsRegex()).get()); 25 } 26 page.addTargetRequests(page.getHtml().links().regex(model.getTargetLinksRegex()).all()); 27 } 28 } 复制代码 JsonProcessor.java View Code   第四步,定义爬虫的入口类 此步不多做解释,就是最基本启动爬虫,然后通过自定义Pipeline对数据进行组装 View Code   第五步,提取html规则,运行测试。 以我的博客园为例,爬虫的一般以文章列表页作为入口页面,本文示例为:https://www.cnblogs.com/zhangyadong/,然后我们需要手动提取文章相关内容的抓取规则(OneBlog中主要使用Xsoup-XPath解析器,使用方式参考链接)。以推荐一款自研的Java版开源博客系统OneBlog一文为例 如图所示,需要抽取的一共为六部分: 文章标题 文章正文内容 文章标签 文章发布日期 文章作者 待
50000+
5万行代码练就真实本领
17年
创办于2008年老牌培训机构
1000+
合作企业
98%
就业率

联系我们

电话咨询

0532-85025005

扫码添加微信