前言
最近正在做一个小说项目,所有的小说资源都是从笔趣阁中爬取,一开始使用的原生jsoup来解析爬取网页内容,但是代码杂乱冗余.
不经意间发现了webmagic这个爬虫框架,琢磨了两天发现确实简单好用,于是整理一篇博客跟大家一起分享学习
如果不了解Jsoup的同学可以先去附录看看jsoup案例讲解,这能更好的帮助我们入门webmagic
webmagic
概念
webmagic是什么?
webmagic是一个开源的Java垂直爬虫框架,目标是简化爬虫的开发流程,让开发者专注于逻辑功能的开发。webmagic的核心非常简单,但是覆盖爬虫的整个流程,也是很好的学习爬虫开发的材料。
webmagic的主要特色:
- 完全模块化的设计,强大的可扩展性。
- 核心简单但是涵盖爬虫的全部流程,灵活而强大,也是学习爬虫入门的好材料。
- 提供丰富的抽取页面API。
- 无配置,但是可通过POJO+注解形式实现一个爬虫。
- 支持多线程。
- 支持分布式。
- 支持爬取js动态渲染的页面。
- 无框架依赖,可以灵活的嵌入到项目中去。
官网地址:http://webmagic.io/
官网讲解
webmagic的结构
官网描述:
WebMagic的结构分为Downloader、PageProcessor、Scheduler、Pipeline四大组件,并由Spider将它们彼此组织起来。
整个爬虫框架分为四大组件,最后由Spider进行协调管理,而这里的Spider可以具象为一句代码:
// 此处的代码看不懂没有关系,后面我们会进行深入探讨和了解 我们只需要知道
// 最后我们定义的WebMagic组件 都会在这一句代码进行统一注入和管理
public static void main(String[] args) {
Spider.create(new GithubRepoPageProcessor())
//从https://github.com/code4craft开始抓
.addUrl("https://github.com/code4craft")
//设置Scheduler,使用Redis来管理URL队列
.setScheduler(new RedisScheduler("localhost"))
//设置Pipeline,将结果以json方式保存到文件
.addPipeline(new JsonFilePipeline("D:\\data\\webmagic"))
//开启5个线程同时执行
.thread(5)
//启动爬虫
.run();
}
然后我们在来看看四大组件:
Downloader:
官网描述:
Downloader负责从互联网上下载页面,以便后续处理。WebMagic默认使用了Apache HttpClient作为下载工具。扫描二维码关注公众号,回复: 13354127 查看本文章
PageProcessor
官网描述:
PageProcessor负责解析页面,抽取有用信息,以及发现新的链接。WebMagic使用Jsoup作为HTML解析工具,并基于其开发了解析XPath的工具Xsoup。在这四个组件中,PageProcessor对于每个站点每个页面都不一样,是需要使用者定制的部分。
PageProcessor是用来解析页面的,我们如何定位到想要爬取的元素,如何获取下一页面地址,如何停止爬取,都统一在这个PageProcessor中定义
PageProcessor在代码中是一个接口,我们需要重写它
// 实现PageProcessor
public class TitleProcessor implements PageProcessor {
// 重写process方法
@Override
public void process(Page page) {
}
}
Scheduler
官网描述;
Scheduler负责管理待抓取的URL,以及一些去重的工作。WebMagic默认提供了JDK的内存队列来管理URL,并用集合来进行去重。也支持使用Redis进行分布式管理。除非项目有一些特殊的分布式需求,否则无需自己定制Scheduler。
Pipeline
官网描述:
Pipeline负责抽取结果的处理,包括计算、持久化到文件、数据库等。WebMagic默认提供了“输出到控制台”和“保存到文件”两种结果处理方案。Pipeline定义了结果保存的方式,如果你要保存到指定数据库,则需要编写对应的Pipeline。对于一类需求一般只需编写一个Pipeline。
Pipeline定义的是我们爬取到的数据如何进行保存,它在代码中同样是一个我们需要去重写的接口
// 实现Pipeline接口
public class TitleProcessor implements Pipeline{
// 重写process方法
@Override
public void process(ResultItems resultItems, Task task) {
}
}
使用Selectable抽取元素
在此之前,我们简单的了解完webmagic四大组件,知道了PageProcessor是用来解析页面的,那么当我们解析完页面如何进行抽取我们想要的内容呢?
这里我们可以使用Selectable
public class GithubRepoPageProcessor implements PageProcessor {
@Override
public void process(Page page) {
// 从url中 获取author regex() 代表可以使用正则表达式进行模糊匹配
String author = page.getUrl().regex("https://github\\.com/(\\w+)/.*").toString();
page.putField("author", author );
// 从网页html中获取name
page.putField("name", page.getHtml().xpath("//h1[@class='entry-title public']/strong/a/text()").toString());
// 当我们获取网页的name 等于 null 时 则不继续爬取
if (page.getResultItems().get("name")==null){
//skip this page
page.setSkip(true);
}
page.putField("readme", page.getHtml().xpath("//div[@id='readme']/tidyText()"));
}
}
除了使用webmagic的方式从抽取元素,我们还可以使用jsoup的方式来进行抽取
它应该是这样的
public class GithubRepoPageProcessor implements PageProcessor {
@Override
public void process(Page page) {
// 获取document 文档树对象
Document document = page.getHtml().getDocument();
// CSS选择器 获取值
String title = document.select("#main > div.novelslist2 > ul").text();
page.putField("title", title);
if (page.getResultItems().get("title")==null){
//skip this page
page.setSkip(true);
}
}
}
熟悉Jsoup的同学看到可以使用Document的方式来抽取元素,是不是感到亲切些呢?
官网解释:
Selectable相关的抽取元素链式API是WebMagic的一个核心功能。使用Selectable接口,你可以直接完成页面元素的链式抽取,也无需去关心抽取的细节。
在刚才的例子中可以看到,page.getHtml()返回的是一个Html对象,它实现了Selectable接口。这个接口包含一些重要的方法,我将它分为两类:抽取部分和获取结果部分。
抽取部分API:
方法 | 说明 | 示例 |
---|---|---|
xpath(String xpath) | 使用XPath选择 | html.xpath("//div[@class=‘title’]") |
$(String selector) | 使用Css选择器选择 | html.$(“div.title”) |
$(String selector,String attr) | 使用Css选择器选择 | html.$(“div.title”,“text”) |
css(String selector) 功能同$(), | 使用Css选择器选择 | html.css(“div.title”) |
links() | 选择所有链接 | html.links() |
regex(String regex) | 使用正则表达式抽取 | html.regex("(.*?)") |
regex(String regex,int group) | 使用正则表达式抽取,并指定捕获组 | html.regex("(.*?)",1) |
replace(String regex, String replacement) | 替换内容 | html.replace("","") |
使用Pipeline保存结果
在使用Selectable抽取到元素以后,我们就可以将我们的数据保存到数据库中了
public class GithubRepoPageProcessor implements PageProcessor, Pipeline {
/**
* 抽取数据
*/
@Override
public void process(Page page) {
// 获取document 文档树对象
Document document = page.getHtml().getDocument();
// CSS选择器 获取值
String title = document.select("#main > div.novelslist2 > ul").text();
page.putField("title", title);
if (page.getResultItems().get("title")==null){
//skip this page
page.setSkip(true);
}
}
/**
* 存储数据
*
* @param resultItems
* @param task
*/
@Override
public void process(ResultItems resultItems, Task task) {
String title = resultItems.get("title");
log.info("获取到数据:"+title);
// 这里可以写入存数据库的逻辑
}
}
ResultItems 是可以map类型的key,value键值对
在我们抽取元素的时候,已经使用page.putField("title", title);
方法存入完数据了!
所以我们可以使用resultItems.get("title");
将它取出来
爬虫的配置、启动和终止
Spider是爬虫启动的入口。在启动爬虫之前,我们需要使用一个PageProcessor
创建一个Spider对象,然后使用run()进行启动。同时Spider的其他组件(Downloader、Scheduler、Pipeline)都可以通过set方法来进行设置。
public static void main(String[] args) {
// 设置 PageProcessor()
Spider.create(new PageProcessor())
//从https://github.com/code4craft开始抓
.addUrl("https://github.com/code4craft")
//设置Pipeline
.addPipeline(new Pipeline())
//开启5个线程同时执行
.thread(5)
//启动爬虫 run()方式启动 会阻塞当前线程执行 如果异步爬取 选择 start()启动
.run();
}
方法 | 说明 | 示例 |
---|---|---|
create(PageProcessor) | 创建Spider | Spider.create(new GithubRepoProcessor()) |
addUrl(String…) | 添加初始的URL | spider .addUrl(“http://webmagic.io/docs/”) |
addRequest(Request…) | 添加初始的Request | spider .addRequest(“http://webmagic.io/docs/”) |
thread(n) | 开启n个线程 | spider.thread(5) |
run() | 启动,会阻塞当前线程执行 | spider.run() |
start()/runAsync() | 异步启动,当前线程继续执行 | spider.start() |
stop() | 停止爬虫 | spider.stop() |
test(String) | 抓取一个页面进行测试 | spider .test(“http://webmagic.io/docs/”) |
addPipeline(Pipeline) | 添加一个Pipeline,一个Spider可以有多个Pipeline | spider .addPipeline(new ConsolePipeline()) |
setScheduler(Scheduler) | 设置Scheduler,一个Spider只能有个一个Scheduler | spider.setScheduler(new RedisScheduler()) |
setDownloader(Downloader) | 设置Downloader,一个Spider只能有个一个Downloader | spider .setDownloader(new SeleniumDownloader()) |
get(String) | 同步调用,并直接取得结果 | ResultItems result = spider .get(“http://webmagic.io/docs/”) |
getAll(String…) | 同步调用,并直接取得一堆结果 | List results = spider .getAll(“http://webmagic.io/docs/”, “http://webmagic.io/xxx”) |
快速开始
项目前准备与说明
此demo项目,用爬取笔趣阁小说为案例进行讲解
在开始前我们需要准备:
- 笔趣阁网址
https://www.bequgexs.com/search.html
- 定位与获取爬取内容的CSS选择器(如何获取CSS选择器,参考本文附录Jsoup)
文章列表页CSS选择器:
标题 | CSS选择器 |
---|---|
文章图片 | #fmimg > img |
标题 | #info > h1 |
作者 | #info > p:nth-child(2) > a |
最新章节 | #info > p:nth-child(5) > a |
摘要 | #intro |
正文第一章地址 | #list > dl > dd:nth-child(15) > a |
文章页CSS选择器:
标题 | CSS选择器 |
---|---|
章节标题 | #main > div > div > div.bookname > h1 |
下一章 | #main > div > div > div.bookname > div.bottem1 > a.next |
文章内容 | #content |
Maven导入依赖
WebMagic主要包含两个jar包:webmagic-core-{version}.jar
和webmagic-extension-{version}.jar
。在项目中添加这两个包的依赖,即可使用WebMagic。
如果整合springboot 建议排除log4j的依赖,否则会出现冲突
<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>
</dependency>
准备CSS选择器
提前准备好CSS选择器,由于这里使用了Lombok记得在maven依赖中进行注入
文章列表页 Css选择器
package com.tuxc.entity;
import lombok.Data;
/**
* 文章列表页 Css选择器
* @author tuxuchen
* @date 2021/11/22 15:14
*/
@Data
public class TitleEntity {
/**
* 封面CSS选择器
*/
private String imgSelector = "#fmimg > img";
/**
* 标题CSS选择器
*/
private String articleNameSelector = "#info > h1";
/**
* 作者CSS选择器
*/
private String authorSelector = "#info > p:nth-child(2) > a";
/**
* 摘要CSS选择器
*/
private String abstractSelector = "#info > p:nth-child(5) > a";
/**
* 最新章节CSS选择器
*/
private String latestChapterSelector = "#intro";
/**
* 第一章节CSS选择器
*/
private String oneChapterSelector = "#list > dl > dd:nth-child(15) > a";
}
正文CSS选择器
package com.tuxc.entity;
import lombok.Data;
/**
* 正文CSS选择器
* @author tuxuchen
* @date 2021/11/22 15:18
*/
@Data
public class ContentEntity {
/**
* 标题选择器
*/
private String titleSelector = "#main > div > div > div.bookname > h1";
/**
* 正文选择器
*/
private String contentSelector = "#content";
/**
* 链接选择器
*/
private String nextChapterSelector = "#main > div > div > div.bookname > div.bottem1 > a.next";
}
PageProcessor组件编写
文章列表页爬取
/**
* 文章列表页爬取
*
* @author tuxuchen
* @date 2021/11/22 15:28
*/
public class TitleProcessor implements PageProcessor {
/**
* 文章列表页CSS选择器
*/
private TitleEntity titleEntity;
/**
* 构造方法赋值
*
* @param titleEntity
*/
public TitleProcessor(TitleEntity titleEntity){
this.titleEntity = titleEntity;
}
@Override
public void process(Page page) {
Document document = page.getHtml().getDocument();
// 获取封面
page.putField("img", document.select(titleEntity.getImgSelector()).attr("abs:src"));
// 获取标题
page.putField("title", document.select(titleEntity.getArticleNameSelector()).text());
// 获取作者
page.putField("author", document.select(titleEntity.getAuthorSelector()).text());
// 获取摘要
page.putField("abstract", document.select(titleEntity.getAbstractSelector()).text());
// 获取最新章节
page.putField("latestChapter", document.select(titleEntity.getLatestChapterSelector()).text());
}
/**
* 抓取配置
* 固定参数 详细配置参考官网
* @return
*/
@Override
public Site getSite() {
/**
* 抓取网站的相关配置,包括编码、抓取间隔、重试次数等
*/
return Site
.me()
.setRetryTimes(3)
.setSleepTime(500)
.setCharset("UTF-8");
}
}
正文内容爬取
/**
* 正文内容爬取
*
* @author tuxuchen
* @date 2021/11/22 15:28
*/
public class ContentProcessor implements PageProcessor {
/**
* 正文CSS选择器
*/
private ContentEntity contentEntity;
/**
* 构造方法赋值
*
* @param contentEntity
*/
public ContentProcessor(ContentEntity contentEntity){
this.contentEntity = contentEntity;
}
@Override
public void process(Page page) {
Document document = page.getHtml().getDocument();
// 文章标题
page.putField("title", document.select(contentEntity.getTitleSelector()).text());
// 如果爬取内容为空则停止爬取(说明小说已经被爬取完了)
if (StringUtils.isBlank(page.getResultItems().get("title"))){
page.setSkip(true);
}
// 正文 这里可以直接获取富文本
page.putField("content", document.select(contentEntity.getContentSelector()).html());
// 获取当前爬取url
page.putField("url", page.getUrl().toString());
//如何爬取下一链接
page.addTargetRequests(page.getHtml().css(contentEntity.getNextChapterSelector()).links().all());
}
/**
* 抓取配置
* 固定参数 详细配置参考官网
* @return
*/
@Override
public Site getSite() {
/**
* 抓取网站的相关配置,包括编码、抓取间隔、重试次数等
*/
return Site
.me()
.setRetryTimes(3)
.setSleepTime(500)
.setCharset("UTF-8");
}
}
Pipeline组件编写
文章列表页数据处理
/**
* 文章列表页 数据处理
*
* @author tuxuchen
* @date 2021/11/22 15:45
*/
public class TitlePipeline implements Pipeline {
/**
* 数据处理
* 我这里只是将数据取出来打印看一下 如果需要存入数据库 则注入mybatis的接口即可
*
* @param resultItems
* @param task
*/
@Override
public void process(ResultItems resultItems, Task task) {
// 获取标题
String img = resultItems.get("img");
System.out.println("获取到封面:" + img);
System.out.println("获取到标题:" + resultItems.get("title"));
System.out.println("获取到作者:" + resultItems.get("author"));
System.out.println("获取到摘要:" + resultItems.get("abstract"));
System.out.println("获取到最新章节:" + resultItems.get("latestChapter"));
}
}
正文内容数据处理
/**
* 正文数据处理
*
* @author tuxuchen
* @date 2021/11/22 15:50
*/
public class ContentPipeline implements Pipeline {
/**
* 数据处理
* 我这里只是将数据取出来打印看一下 如果需要存入数据库 则注入mybatis的接口即可
*
* @param resultItems
* @param task
*/
@Override
public void process(ResultItems resultItems, Task task) {
System.out.println("获取到正文标题:" + resultItems.get("title"));
System.out.println("获取到正文;" + resultItems.get("content"));
System.out.println("获取到当前url:" + resultItems.get("url"));
}
}
Service接口编写
/**
* 爬虫业务实现
*
* @author tuxuchen
* @date 2021/11/22 15:26
*/
@Slf4j
public class CrawlerService {
/**
* 开启爬虫
*
* @param url 从什么地址开始爬取
*/
public void enableCrawler(String url){
/**
* 1.先把列表页的数据
* 2.列表页数据爬取完成以后 获取第一章链接
* 3.爬取正文数据
*/
TitleEntity title = new TitleEntity();
Spider.create(new TitleProcessor(title))
.addUrl(url)
.addPipeline(new TitlePipeline())
// 一个线程
.thread(1)
// 阻塞爬取
.run();
// 获取第一章链接地址
Document document = getDocument(url);
url = document.select(title.getOneChapterSelector()).get(0).attr("abs:href");
Spider.create(new ContentProcessor(new ContentEntity()))
.addUrl(url)
.addPipeline(new ContentPipeline())
// 5个线程
.thread(5)
// 异步爬取
.start();
}
/**
* 从url上获取文档,为了防止反爬虫,这是一些头字段
* 如果失败,会重试10次
*
* @param url 爬取地址
* @return document
*/
private Document getDocument(String url) {
// 重试次数
int count = 10;
boolean flag = true;
Document document = null;
while (flag) {
try {
document = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36")
.get();
flag = false;
} catch (IOException e) {
if (count-- != 0) {
System.out.println("网页获取失败,原因:" + e.getMessage());
System.out.println("开始第" + (10 - count) + "次重试");
} else {
System.out.println("获取文档未知异常:" + e.getMessage());
}
}
}
return document;
}
}
测试运行
整体项目结构
编写启动类
/**
* 爬虫启动类
* @author tuxuchen
* @date 2021/11/22 16:00
*/
public class CrawlerMain {
public static void main(String[] args) {
CrawlerService crawlerService = new CrawlerService();
// 从什么路径开始爬取
crawlerService.enableCrawler("https://www.bequgexs.com/0/2/");
}
}
关于如何启动与停止爬虫
可以 将Spider对象在Map集合中进行存储起来
以便于进行后续操作
/**
* 爬虫业务实现
*
* @author tuxuchen
* @date 2021/11/22 15:26
*/
public class CrawlerService {
/**
* 同步开关
*/
private Map<String, Spider> onOff;
{
onOff = new HashMap<>();
}
/**
* 开启爬虫
*
* @param url 从什么地址开始爬取
*/
public void enableCrawler(String url){
/**
* 1.先把列表页的数据
* 2.列表页数据爬取完成以后 获取第一章链接
* 3.爬取正文数据
*/
TitleEntity title = new TitleEntity();
Spider.create(new TitleProcessor(title))
.addUrl(url)
.addPipeline(new TitlePipeline())
// 一个线程
.thread(1)
// 阻塞爬取
.run();
// 获取第一章链接地址
Document document = getDocument(url);
url = document.select(title.getOneChapterSelector()).get(0).attr("abs:href");
// 将Spider对象在Map集合中进行存储起来
Spider spider = Spider.create(new ContentProcessor(new ContentEntity()))
.addUrl(url)
.addPipeline(new ContentPipeline());
onOff.put("spider01", spider);
spider.thread(5).start();
}
// 传入Map中的key就可以停止了
public void stopCrawler(String name){
Spider spider = onOff.get(name);
spider.stop();
}
附录
Jsoup
前言
webmagic是对jsoup进一步封装及扩展,为了能够更好的学习webmagic,我们先了解完原生使用Jsoup是如何爬取网页的,再去学习webmagic.两者相互对比,效率更高
Jsoup简介
jsoup 是一款Java 的HTML解析器,可直接解析某个URL地址、HTML文本内容。它提供了一套非常省力的API,可通过DOM,CSS以及类似于jQuery的操作方法来取出和操作数据。
Jsoup的主要功能
1)从一个URL,文件或字符串中解析HTML
2)使用DOM或CSS选择器来查找、取出数据
3)可操作HTML元素、属性、文本
注意:jsoup是基于MIT协议发布的,可放心使用于商业项目。
案例讲解
我们用搜索笔趣阁的文章为案例来进一步探讨jsoup
笔趣阁搜索网址:https://www.bequgexs.com/search.html?name=圣墟
页面分析
爬虫最重要的是html页面分析,我们只有了解完页面的结构,才能够定位到我们需要的数据,并且把它给取出来
1.当我们输入了想要搜索的小说名称以后,在列表中会查询出来我们想要的数据
2.当我们定位到,我们想要的数据,发现它是一个<a>
标签来包裹的
3.同理 作者,最新章节等等,我们都能够在html文档节点中找到它们相应的位置
4.而整个搜索的内容是一个<ul>
标签来包裹的
5.我们复制出<ul>
的CSS选择器,以便定位的时候使用 示例:#main > div.novelslist2 > ul
创建项目导入依赖
创建Maven简单项目,导入jsoup依赖包
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.12.1</version>
</dependency>
demo编写
/**
* jsoup测试案例
* @author tuxuchen
* @date 2021/11/18 18:17
*/
public class JsoupTest {
public static void main(String[] args) {
JsoupTest test = new JsoupTest();
test.findSearch("圣墟");
}
/**
* 搜索功能
*
* @param name
* @return
*/
public void findSearch(String name) {
// 获取搜索页文档树
Document document = getDocument("https://www.bequgexs.com/search.html?name=" + name);
if (Objects.isNull(document)) {
System.out.println("文档树获取失败");
return;
}
// 取出 <ul> 标签内
Element ul = document.select("#main > div.novelslist2 > ul").get(0);
if (ul.isBlock()) {
// 如果获取成功
// 从<ul> 标签 取出 <li> 标签
Elements li = ul.getElementsByTag("li");
// 从1开始 遍历 为什么从1开始 是因为我们不需要第一个li标签 第一个li标签内是序号
for (int i = 1; i < li.size(); i++) {
Element e = li.get(i);
// 取出 li 标签内的 <a> 标签 <a>内就是我们需要的内容
Elements a = e.getElementsByTag("a");
String book = "";
// 遍历取出每个 <a> 标签
for (int r = 0; r < a.size(); r++){
String text = a.get(r).text();
book = book + text + ":";
}
System.out.println(book);
}
}
}
/**
* document 是浏览器对象 是文档树 这跟前端document是一样的
*
* 从url上获取文档数,为了防止反爬虫,这是一些头字段
* 如果失败,会重试10次
*
* @param url 爬取地址
* @return document
*/
private Document getDocument(String url) {
// 重试次数
int count = 10;
boolean flag = true;
Document document = null;
while (flag) {
try {
document = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36")
.get();
flag = false;
} catch (IOException e) {
if (count-- != 0) {
System.out.println(("网页获取失败,原因:" + e.getMessage()));
System.out.println("开始第" + (10 - count) + "次重试");
} else {
System.out.println("获取文档未知异常:" + e.getMessage());
}
}
}
return document;
}
}
测试运行