詳細(xì)介紹如何自研一款"博客搬家"功能
接上篇DBlog开源博客新增博客迁移功能(支持多个站点)通过代码,详细介绍如何自研一个“博客搬家”的功能。
使用教程
上文已经介绍了 如何抓取慕课的文章,本文就不再抓慕课了,咱们换个人抓。目前,该功能已内置了以下几个平台(imooc、csdn、cnblogs和iteye),根据不同的平台,程序已默认了一套抓取规则,如下图
cnblogs抓取规则:
使用时,只需要手动指定以下几项配置即可
其他信息在选择完博文平台后,程序会自动补充完整。圈中必填的几项配置如下:
选择博文平台
:选择待操作的博文平台(程序会自动生成对应平台的抓取规则)自动转存图片
:勾选时默认将文章中的图片转存到七牛云中(需提前配置七牛云)文章分类
:是指抓取的文章保存到本地数据库中的文章分类用户ID
:是指各平台中,登陆完成后的用户ID,程序中已给出了对应获取的方法文章总页数
:是指待抓取的用户所有文章的页数Cookie(非必填)
:只在必须需要登陆才能获取数据时指定,获取方式如程序中所示
在指定完博文平台、用户ID和文章总页数后,爬虫的其他配置项就会自动补充完整,最后直接执行该程序即可。 注意:默认同步过来的文章为“草稿”状态,主要是为了防止抓取的内容错误,而直接显示到网站前台,造成不必要的麻烦。所以,需要手动确认无误后修改发布状态。另外,针对一些做了防盗链的网站,我们在使用“文章搬运工”时,还要勾选上“自动转存图片”,至于为何要这么做,在下面会有解释。
关于“文章搬运工”功能的实现
“文章搬运工”功能听起来觉得高大上,类似的比如CSDN和cnblogs里的“博客搬家”功能,其实实现起来很简单。下面听我道一道,你也可以轻松做出一个“博客搬家”功能!
“博客搬家”首先需要克服的问题无非就是:怎么从别人的页面中提取出相关的文章信息后保存到自己的服务器中。说到页面提取,可能很多同学不约而同的就想到了:爬虫!没错,就是通过最基础的网络爬虫就可实现,而OneBlog的文章搬运工功能就是基于爬虫实现的。
WebMagic是一个简单灵活的Java爬虫框架。之所以选择该框架,完全依赖于它的优秀特性:
- 完全模块化的设计,强大的可扩展性。
- 核心简单但是涵盖爬虫的全部流程,灵活而强大,也是学习爬虫入门的好材料。
- 提供丰富的抽取页面API。
- 无配置,但是可通过POJO+注解形式实现一个爬虫。
- 支持多线程。
- 支持分布式。
- 支持爬取js动态渲染的页面。
- 无框架依赖,可以灵活的嵌入到项目中去
关于webMagic的其他详细介绍,请去webMagic的官网查阅,本文不做赘述。
下面针对OneBlog中的“文章搬运工”功能做一下简单的分析。
第一步,添加依赖包
<dependency>
<groupId>us.codecraft</groupId>
<artifactId>webmagic-core</artifactId>
<version>0.7.3</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>us.codecraft</groupId>
<artifactId>webmagic-extension</artifactId>
<version>0.7.3</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
webmagic-core是WebMagic核心部分,只包含爬虫基本模块和基本抽取器。WebMagic-core的目标是成为网页爬虫的一个教科书般的实现。
webmagic-extension是WebMagic的主要扩展模块,提供一些更方便的编写爬虫的工具。包括注解格式定义爬虫、JSON、分布式等支持。
第二步,抽取爬虫规则
为了方便扩展,我们要抽象出webMagic爬虫运行时需要的基本属性到BaseModel.java
/**
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @website https://www.zhyd.me
* @version 1.0
* @date 2018/7/23 13:33
*/
@Data
public class BaseModel {
/* 文章标题提取规则 */
private String titleRegex;
/* 文章正文提取规则 */
private String contentRegex;
/* 文章发布日期提取规则 */
private String releaseDateRegex;
/* 文章作者提取规则 */
private String authorRegex;
/* 文章提取规则 */
private String targetLinksRegex;
/* 文章标签提取规则 */
private String tagRegex;
private String keywordsRegex = "http://meta [@name=keywords]/@content";
private String descriptionRegex = "http://meta [@name=description]/@content";
private String domain;
private String charset = "utf8";
/* 每次爬取页面时的等待时间 */
private int sleepTime = 1000;
/* 抓取失败时重试的次数 */
private int retryTimes = 2;
/* 线程个数 */
private int threadCount = 1;
/* 抓取入口地址 */
private String[] entryUrls;
/* 退出方式{1:等待时间(waitTime必填),2:抓取到的url数量(urlCount必填)} */
private int exitWay = 1;
/* 单位:秒 */
private int waitTime = 60;
private int urlCount = 100;
private List<Cookie> cookies = new ArrayList<>();
private Map<String, String> headers = new HashMap<>();
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";
private String uid;
private Integer totalPage;
/* 保留字段,针对ajax渲染的页面 */
private Boolean ajaxRequest = false;
/* 是否转存图片 */
private boolean convertImg = false;
getter、setter....
}
如上方代码中所示(只给出了关键代码并且已经给了注释),我们抽取出了基本的抓取规则和针对不同平台设置的网站属性(domain、cookies和headers等)。
第三步,编写解析器
因为“博客迁移功能”目前只涉及到页面的解析、抽取,所以,我们只需要实现webMagic的PageProcessor接口即可。这里有个关键点需要注意:随着网络技术的发展,现在前后端分离的网站越来越多,而前后端分离的网站基本通过ajax渲染页面。这种情况下,httpClient获取到的页面内容只是js渲染前的html,因此按照常规的解析方式,是解析不到这部分内容的,因此我们需要针对普通的html页面和js渲染的页面分别提供解析器。本文主要讲解针对普通html的解析方式,至于针对js渲染的页面的解析,以后会另行写文介绍。
/**
* 统一对页面进行解析处理
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @version 1.0
* @website https://www.zhyd.me
* @date 2018/7/31 17:37
*/
@Slf4j
public class BaseProcessor implements PageProcessor {
private static BaseModel model;
BaseProcessor() {
}
BaseProcessor(BaseModel m) {
model = m;
}
@Override
public void process(Page page) {
Processor processor = new HtmlProcessor();
if (model.getAjaxRequest()) {
processor = new JsonProcessor();
}
processor.process(page, model);
}
@Override
public Site getSite() {
Site site = Site.me()
.setCharset(model.getCharset())
.setDomain(model.getDomain())
.setSleepTime(model.getSleepTime())
.setRetryTimes(model.getRetryTimes());
//添加抓包获取的cookie信息
List<Cookie> cookies = model.getCookies();
if (CollectionUtils.isNotEmpty(cookies)) {
for (Cookie cookie : cookies) {
if (StringUtils.isEmpty(cookie.getDomain())) {
site.addCookie(cookie.getName(), cookie.getValue());
continue;
}
site.addCookie(cookie.getDomain(), cookie.getName(), cookie.getValue());
}
}
//添加请求头,有些网站会根据请求头判断该请求是由浏览器发起还是由爬虫发起的
Map<String, String> headers = model.getHeaders();
if (MapUtils.isNotEmpty(headers)) {
Set<Map.Entry<String, String>> entrySet = headers.entrySet();
for (Map.Entry<String, String> entry : entrySet) {
site.addHeader(entry.getKey(), entry.getValue());
}
}
return site;
}
}
Processor.java接口,只提供一个process方法供实际的解析器实现
/**
* 页面解析接口
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @version 1.0
* @website https://www.zhyd.me
* @date 2018/7/31 17:37
*/
public interface Processor {
void process(Page page, BaseModel model);
}
HtmlProcessor.java
/**
* 解析处理普通的Html网页
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @version 1.0
* @website https://www.zhyd.me
* @date 2018/7/31 17:37
*/
public class HtmlProcessor implements Processor {
@Override
public void process(Page page, BaseModel model) {
Html pageHtml = page.getHtml();
String title = pageHtml.xpath(model.getTitleRegex()).get();
String source = page.getRequest().getUrl();
if (!StringUtils.isEmpty(title) && !"null".equals(title) && !Arrays.asList(model.getEntryUrls()).contains(source)) {
page.putField("title", title);
page.putField("source", source);
page.putField("releaseDate", pageHtml.xpath(model.getReleaseDateRegex()).get());
page.putField("author", pageHtml.xpath(model.getAuthorRegex()).get());
page.putField("content", pageHtml.xpath(model.getContentRegex()).get());
page.putField("tags", pageHtml.xpath(model.getTagRegex()).all());
page.putField("description", pageHtml.xpath(model.getDescriptionRegex()).get());
page.putField("keywords", pageHtml.xpath(model.getKeywordsRegex()).get());
}
page.addTargetRequests(page.getHtml().links().regex(model.getTargetLinksRegex()).all());
}
}
JsonProcessor.java
/**
* 解析处理Ajax渲染的页面(待完善)
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @version 1.0
* @website https://www.zhyd.me
* @date 2018/7/31 17:37
*/
public class JsonProcessor implements Processor {
@Override
public void process(Page page, BaseModel model) {
String rawText = page.getRawText();
String title = new JsonPathSelector(model.getTitleRegex()).select(rawText);
if (!StringUtils.isEmpty(title) && !"null".equals(title)) {
page.putField("title", title);
page.putField("releaseDate", new JsonPathSelector(model.getReleaseDateRegex()).select(rawText));
page.putField("author", new JsonPathSelector(model.getAuthorRegex()).select(rawText));
page.putField("content", new JsonPathSelector(model.getContentRegex()).select(rawText));
page.putField("source", page.getRequest().getUrl());
}
page.addTargetRequests(page.getHtml().links().regex(model.getTargetLinksRegex()).all());
}
}
第四步,定义爬虫的入口类
此步不多做解释,就是最基本启动爬虫,然后通过自定义Pipeline对数据进行组装
/**
* 爬虫入口
*
* @author yadong.zhang (yadong.zhang0415(a)gmail.com)
* @version 1.0
* @website https://www.zhyd.me
* @date 2018/7/23 10:38
*/
@Slf4j
public class ArticleSpiderProcessor extends BaseProcessor implements BaseSpider<Article> {
private BaseModel model;
private PrintWriter writer;
private ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
private ArticleSpiderProcessor() {
}
public ArticleSpiderProcessor(BaseModel model, PrintWriter writer) {
super(model);
this.model = model;
this.writer = writer;
}
public ArticleSpiderProcessor(BaseModel model) {
super(model);
this.model = model;
}
/**
* 运行爬虫并返回结果
*
* @return
*/
@Override
public List<Article> run() {
List<String> errors = validateModel(model);
if (CollectionUtils.isNotEmpty(errors)) {
WriterUtil.writer2Html(writer, "校验不通过!请依据下方提示,检查输入参数是否正确......");
for (String error : errors) {
WriterUtil.writer2Html(writer, ">> " + error);
}
return null;
}
List<Article> articles = new LinkedList<>();
WriterUtil.writer2Html(writer, ">> 爬虫初始化完成,共需抓取 " + model.getTotalPage() + " 页数据...");
Spider spider = Spider.create(new ArticleSpiderProcessor())
.addUrl(model.getEntryUrls())
.addPipeline((resultItems, task) -> {
Map<String, Object> map = resultItems.getAll();
String title = String.valueOf(map.get("title"));
if (StringUtils.isEmpty(title) || "null".equals(title)) {
return;
}
String content = String.valueOf(map.get("content"));
String source = String.valueOf(map.get("source"));
String releaseDate = String.valueOf(map.get("releaseDate"));
String author = String.valueOf(map.get("author"));
String description = String.valueOf(map.get("description"));
description = StringUtils.isNotEmpty(description) ? description.replaceAll("
| ", "")
: content.length() > 100 ? content.substring(0, 100) : content;
String keywords = String.valueOf(map.get("keywords"));
keywords = StringUtils.isNotEmpty(keywords) && !"null".equals(keywords) ? keywords.replaceAll(" +|,", ",").replaceAll(",,", ",") : null;
List<String> tags = (List<String>) map.get("tags");
log.info(String.format(">> 正在抓取 -- %s -- %s -- %s -- %s", source, title, releaseDate, author));
WriterUtil.writer2Html(writer, String.format(">> 正在抓取 -- <a href="%s" target="_blank">%s</a> -- %s -- %s", source, title, releaseDate, author));
articles.add(new Article(title, content, author, releaseDate, source, description, keywords, tags));
})
.thread(model.getThreadCount());
// 启动爬虫
spider.run();
return articles;
}
private <T> List<String> validateModel(T t) {
Validator validator = factory.getValidator();
Set<ConstraintViolation<T>> constraintViolations = validator.validate(t);
List<String> messageList = new ArrayList<>();
for (ConstraintViolation<T> constraintViolation : constraintViolations) {
messageList.add(constraintViolation.getMessage());
}
return messageList;
}
}
第五步,提取html规则,运行测试
以我的博客园为例,爬虫的一般以文章列表页作为入口页面,本文示例为:https://www.cnblogs.com/zhangyadong/ ,然后我们需要手动提取文章相关内容的抓取规则(OneBlog中主要使用Xsoup-XPath解析器,使用方式参考链接)。以推荐一款自研的Java版开源博客系统OneBlog一文为例
如图所示,需要抽取的一共为六部分:
- 文章标题
- 文章正文内容
- 文章标签
- 文章发布日期
- 文章作者
- 待抽取的其他文章列表
通过f12查看页面结构,如下
整理相关规则如下:
注:“待抽取的其他文章链接”就是根据这篇文章的链接抽取出的规则
到这一步为止,基本的文章信息抽取规则就以获取完毕,接下来就跑一下测试
@Test
public void cnblogSpiderTest() {
BaseSpider<Article> spider = new ArticleSpiderProcessor(new CnblogModel().setUid("zhangyadong")
.setTotalPage(1)
.setDomain("www.cnblogs.com")
.setTitleRegex("http://a[@id=cb_post_title_url]/html()")
.setAuthorRegex("http://div[@class=postDesc]/a[1]/html()")
.setReleaseDateRegex("http://span[@id=post-date]/html()")
.setContentRegex("http://div[@id=cnblogs_post_body]/html()")
.setTagRegex("http://div[@id=EntryTag]/a/html()")
.setTargetLinksRegex(".*www\.cnblogs\.com/zhangyadong/p/[\w\d]+\.html")
.setHeader("Host", "www.cnblogs.com")
.setHeader("Referer", "https://www.cnblogs.com/"));
spider.run();
}
Console控制台打印数据
2018-09-12 11:50:49 [us.codecraft.webmagic.Spider:306] INFO - Spider www.cnblogs.com started!
2018-09-12 11:50:51 [com.zyd.blog.spider.processor.ArticleSpiderProcessor:89] INFO - >> 正在抓取 -- https://www.cnblogs.com/zhangyadong/p/oneblog.html -- 推荐一款自研的Java版开源博客系统OneBlog -- 2018-09-11 09:53 -- HandsomeBoy丶
2018-09-12 11:50:52 [us.codecraft.webmagic.Spider:338] INFO - Spider www.cnblogs.com closed! 2 pages downloaded.
如上,文章已成功被抓取,剩下的,无非就是要么保存到文件中,要么持久化到数据库里。OneBlog中是直接保存到了数据库里。
关于文章图片转存
为什么要添加“文章转存”功能?
那是因为一些网站对本站内的静态资源做了“防盗链”,而所谓的“防盗链”说简单点就是:我的东西别人不能用,得需要我授权才可。这样做的好处就是,不会让自己的劳动成果白白给别人做了嫁衣。那么,针对这一特性,如果在“文章搬运”时,原文图片未经处理就原封不动的保存下来,以开源博客这篇文章为例,可能就会碰到如下情况
如上图,有一些图片无法显示,在控制台中可以看到这些图片全是报错403,也就是未授权,也就是所谓的被原站做了“防盗链”!这个时候,我们在抓取文章时就需要将原文的图片全部转存到自己服务器上,如此一来就解决了“被防盗链”的问题。
针对这一问题,OneBlog中则是通过正则表达式,将所有img标签的src里的网络文件下载下来后转存到七牛云中。简单代码如下:
private static final Pattern PATTERN = Pattern.compile("<img[^>]+src\s*=\s*['"]([^'"]+)['"][^>]*>");
private String parseImgForHtml(String html, String qiniuBasePath, PrintWriter writer) {
if (StringUtils.isEmpty(html)) {
return null;
}
Matcher m = PATTERN.matcher(html);
Set<String> imgUrlSet = new HashSet<>();
while (m.find()) {
String imgUrl = m.group(1);
imgUrlSet.add(imgUrl);
}
if (!CollectionUtils.isEmpty(imgUrlSet)) {
for (String imgUrl : imgUrlSet) {
String qiniuImgPath = ImageDownloadUtil.convertToQiniu(imgUrl);
if (StringUtils.isEmpty(qiniuImgPath)) {
continue;
}
html = html.replaceAll(imgUrl, qiniuBasePath + qiniuImgPath);
}
}
return html;
}
注:以上代码中最关键的就是提取img标签的src属性值的正则表达式
ImageDownloadUtil.convertToQiniu方法如下
/**
* 将网络图片转存到七牛云
*
* @param imgUrl 网络图片地址
*/
public static String convertToQiniu(String imgUrl) {
log.debug("download img >> %s", imgUrl);
String qiniuImgPath = null;
try (InputStream is = getInputStreamByUrl(checkUrl(imgUrl));
ByteArrayOutputStream outStream = new ByteArrayOutputStream();) {
byte[] buffer = new byte[1024];
int len = 0;
while ((len = is.read(buffer)) != -1) {
outStream.write(buffer, 0, len);
}
qiniuImgPath = QiniuApi.getInstance()
.withFileName("temp." + getSuffixByUrl(imgUrl), QiniuUploadType.SIMPLE)
.upload(outStream.toByteArray());
} catch (Exception e) {
log.error("Error.", e);
}
return qiniuImgPath;
}
(注:以上代码只是简单示例了一下核心代码,具体代码请参考我的开源博客:OneBlog)
总结
看完了我上面的介绍,你应该可以发现,其实技术实现起来,并没有太大的难点。主要重难点无非就一个:如何编写提取html内容的规则。规则一旦确定了,剩下的无非就是粘贴复制就能完成的代码而已。
最后声明
- 本工具开发初衷只是用来迁移 自己的文章 所用,因此不可用该工具恶意窃取他人劳动成果!
- 因不听劝阻,使用该工具恶意窃取他们劳动成果而造成的一切不良后果,本人表示:坚决不背锅!
- 如果该工具不好用,你们绝对不能打我!
- 有问题、建议,请留言,或者去gitee上提Issues!
共同學(xué)習(xí),寫下你的評論
評論加載中...
作者其他優(yōu)質(zhì)文章