1.网络爬虫

1.1 概述

在大数据时代,信息的采集是一项重要的工作,而互联网中的数据是海量的,如果单纯靠人力进行信息采集,不仅低效繁琐,搜集的成本也会提高。如何自动高效地获取互联网中我们感兴趣的信息并为我们所用是一个重要的问题,而爬虫技术就是为了解决这些问题而生的。

网络爬虫(Web crawler)也叫做网络机器人,可以代替人们自动地在互联网中进行数据信息的采集与整理。它是一种按照一定的规则,自动地抓取万维网信息的程序或者脚本,可以自动采集所有其能够访问到的页面内容,以获取相关数据。

从功能上来讲,爬虫一般分为数据采集,处理,储存三个部分。爬虫从一个或若干初始网页的URL开始,获得初始网页上的URL,在抓取网页的过程中,不断从当前页面上抽取新的URL放入队列,直到满足系统的一定停止条件。

1.2 为什么学网络爬虫

  1. 可以实现搜索引擎
    我们学会了爬虫编写之后,就可以利用爬虫自动地采集互联网中的信息,采集回来后进行相应的存储或处理,在需要检索某些信息的时候,只需在采集回来的信息中进行检索,即实现了私人的搜索引擎。

  2. 大数据时代,可以让我们获取更多的数据源。
    在进行大数据分析或者进行数据挖掘的时候,需要有数据源进行分析。我们可以从某些提供数据统计的网站获得,也可以从某些文献或内部资料中获得,但是这些获得数据的方式,有时很难满足我们对数据的需求,而手动从互联网中去寻找这些数据,则耗费的精力过大。此时就可以利用爬虫技术,自动地从互联网中获取我们感兴趣的数据内容,并将这些数据内容爬取回来,作为我们的数据源,再进行更深层次的数据分析,并获得更多有价值的信息。

  3. 对于很多SEO从业者来说,为了更好的完成工作,那么就必须要对搜索引擎的工作原理非常清楚,同时也需要掌握搜索引擎爬虫的工作原理。而学习爬虫,可以更深层次地理解搜索引擎爬虫的工作原理,这样在进行搜索引擎优化时,才能知己知彼,百战不殆。

1.3 爬虫分类

网络爬虫按照系统结构和实现技术,大致可以分为以下几种类型:通用网络爬虫、聚焦网络爬虫、增量式网络爬虫、深层网络爬虫。 实际的网络爬虫系统通常是几种爬虫技术相结合实现的

1.通用网络爬虫

通用网络爬虫又称全网爬虫(Scalable Web Crawler),爬行对象从一些种子 URL 扩充到整个 Web,主要为门户站点搜索引擎和大型 Web 服务提供商采集数据。
这类网络爬虫的爬行范围和数量巨大,对于爬行速度和存储空间要求较高,对于爬行页面的顺序要求相对较低,同时由于待刷新的页面太多,通常采用并行工作方式,但需要较长时间才能刷新一次页面。
简单的说就是互联网上抓取所有数据。

2.聚焦网络爬虫

聚焦网络爬虫(Focused Crawler),又称主题网络爬虫(Topical Crawler),是指选择性地爬行那些与预先定义好的主题相关页面的网络爬虫。
和通用网络爬虫相比,聚焦爬虫只需要爬行与主题相关的页面,极大地节省了硬件和网络资源,保存的页面也由于数量少而更新快,还可以很好地满足一些特定人群对特定领域信息的需求 。
简单的说就是互联网上只抓取某一种数据。

3.增量式网络爬虫

增量式网络爬虫(Incremental Web Crawler)是 指 对 已 下 载 网 页 采 取 增量式更新和只爬行新产生的或者已经发生变化网页的爬虫,它能够在一定程度上保证所爬行的页面是尽可能新的页面。
和周期性爬行和刷新页面的网络爬虫相比,增量式爬虫只会在需要的时候爬行新产生或发生更新的页面 ,并不重新下载没有发生变化的页面,可有效减少数据下载量,及时更新已爬行的网页,减小时间和空间上的耗费,但是增加了爬行算法的复杂度和实现难度。
简单的说就是互联网上只抓取刚刚更新的数据。

4.Deep Web 爬虫

Web 页面按存在方式可以分为表层网页(Surface Web)和深层网页(Deep Web,也称 Invisible Web Pages 或 Hidden Web)。
表层网页是指传统搜索引擎可以索引的页面,以超链接可以到达的静态网页为主构成的 Web 页面。
Deep Web 是那些大部分内容不能通过静态链接获取的、隐藏在搜索表单后的,只有用户提交一些关键词才能获得的 Web 页面。

2.HttpClient

网络爬虫就是用程序帮助我们访问网络上的资源,我们一直以来都是使用HTTP协议访问互联网的网页,网络爬虫需要编写程序,在这里使用同样的HTTP协议访问网页。

这里我们使用Java的HTTP协议客户端 HttpClient这个技术,来实现抓取网页数据。

2.1 入门程序

创建Maven工程,在POM文件中添加依赖:

<!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-log4j12 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.25</version>
<!--<scope>test</scope>-->
</dependency>

resources目录下创建日志输出文件log4j.properties

log4j.rootLogger=DEBUG,A1
log4j.logger.cn.itcast = DEBUG

log4j.appender.A1=org.apache.log4j.ConsoleAppender
log4j.appender.A1.layout=org.apache.log4j.PatternLayout
log4j.appender.A1.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss,SSS} [%t] [%c]-[%p] %m%n

编写测试代码:

public class CrawlerFirst {

public static void main(String[] args) throws Exception {
//1. 打开浏览器,创建HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();

//2. 输入网址,发起get请求创建HttpGet对象
HttpGet httpGet = new HttpGet("http://news.baidu.com");

//3.按回车,发起请求,返回响应,使用HttpClient对象发起请求
CloseableHttpResponse response = httpClient.execute(httpGet);

//4. 解析响应,获取数据
//判断状态码是否是200
if (response.getStatusLine().getStatusCode() == 200) {
HttpEntity httpEntity = response.getEntity();
String content = EntityUtils.toString(httpEntity, "utf8");

System.out.println(content);
}
}
}

测试结果:可以获取到页面数据

2.2 GET请求

使用GET请求访问百度新闻,请求url地址: http://news.baidu.com/

测试类:

public class HttpGetTest {

public static void main(String[] args) {
//创建HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();

//创建HttpGet对象,设置url访问地址
HttpGet httpGet = new HttpGet("http://news.baidu.com");

CloseableHttpResponse response = null;
try {
//使用HttpClient发起请求,获取response
response = httpClient.execute(httpGet);

//解析响应
if (response.getStatusLine().getStatusCode() == 200) {
String content = EntityUtils.toString(response.getEntity(), "utf8");
System.out.println(content.length());
}

} catch (IOException e) {
e.printStackTrace();
}finally {
//关闭response
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
httpClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

仔细观察控制台,可以发现是get请求,并且返回200状态码,说明响应成功。

http-outgoing-0 >> GET / HTTP/1.1
http-outgoing-0 >> Host: news.baidu.com
http-outgoing-0 << "HTTP/1.1 200 OK[\r][\n]"

2.3 带参数的GET请求

在知乎中搜索关于COVID-19的信息,地址为:https://www.zhihu.com/search?type=content&q=covid19

我们请求路径是:https://www.zhihu.com/search,后面的是搜索类型(type)和关键字(q)。

因为知乎搜索时,必须制定搜索类型,所以我们这里封装了两个参数。如果只有一个查询参数,只需setParameter一次即可

测试类:

public class HttpGetParamTest {

public static void main(String[] args) throws Exception {
//创建HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();

//设置请求地址是:https://www.zhihu.com/search?type=content&q=covid19
//创建URIBuilder
URIBuilder uriBuilder = new URIBuilder("https://www.zhihu.com/search");
//设置参数
uriBuilder.setParameter("type","search").setParameter("q","covid19");

//创建HttpGet对象,设置url访问地址
HttpGet httpGet = new HttpGet(uriBuilder.build());

System.out.println("发起请求的信息:"+httpGet);

CloseableHttpResponse response = null;
try {
//使用HttpClient发起请求,获取response
response = httpClient.execute(httpGet);

//解析响应
if (response.getStatusLine().getStatusCode() == 200) {
String content = EntityUtils.toString(response.getEntity(), "utf8");
System.out.println(content.length());
}

} catch (IOException e) {
e.printStackTrace();
}finally {
//关闭response
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
httpClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

仔细观察控制台,可以发现是get请求,并且请求中带有查询参数,并且返回200状态码,说明响应成功。

http-outgoing-0 >> GET /search?type=search&q=covid19 HTTP/1.1
http-outgoing-0 >> Host: www.zhihu.com
http-outgoing-0 << "HTTP/1.1 200 OK[\r][\n]"

2.4 POST请求

使用POST请求访问百度新闻,请求url地址: http://news.baidu.com/

测试类:

public class HttpPostTest {

public static void main(String[] args) {
//创建HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();

//创建HttpPost对象,设置url访问地址
HttpPost httpPost = new HttpPost("http://news.baidu.com");

CloseableHttpResponse response = null;
try {
//使用HttpClient发起请求,获取response
response = httpClient.execute(httpPost);

//解析响应
if (response.getStatusLine().getStatusCode() == 200) {
String content = EntityUtils.toString(response.getEntity(), "utf8");
System.out.println(content.length());
}

} catch (IOException e) {
e.printStackTrace();
}finally {
//关闭response
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
httpClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

仔细观察控制台,可以发现是post请求,并且返回200状态码,说明响应成功。

http-outgoing-0 >> POST / HTTP/1.1
http-outgoing-0 >> Host: news.baidu.com
http-outgoing-0 << "HTTP/1.1 200 OK[\r][\n]"

2.5 带参数的POST请求

在知乎中搜索关于COVID-19的信息,地址为:https://www.zhihu.com/search?type=content&q=covid19

我们请求路径是:https://www.zhihu.com/search,后面的是搜索类型(type)和关键字(q)。

url地址没有参数,参数keys=java放到表单中进行提交

测试类:

public class HttpPostParamTest {

public static void main(String[] args) throws Exception {
//创建HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();

//创建HttpPost对象,设置url访问地址
HttpPost httpPost = new HttpPost("http://www.zhihu.com/search");

//声明List集合,封装表单中的参数
List<NameValuePair> params = new ArrayList<NameValuePair>();
//设置请求地址是:https://www.zhihu.com/search?type=content&q=covid19
params.add(new BasicNameValuePair("type","content"));
params.add(new BasicNameValuePair("q","covid19"));
//创建表单的Entity对象,第一个参数就是封装好的表单数据,第二个参数就是编码
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(params,"utf8");

//设置表单的Entity对象到Post请求中
httpPost.setEntity(formEntity);

CloseableHttpResponse response = null;
try {
//使用HttpClient发起请求,获取response
response = httpClient.execute(httpPost);

//解析响应
if (response.getStatusLine().getStatusCode() == 200) {
String content = EntityUtils.toString(response.getEntity(), "utf8");
System.out.println(content.length());
}

} catch (IOException e) {
e.printStackTrace();
}finally {
//关闭response
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
httpClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

仔细观察控制台,可以发现是post请求。

http-outgoing-0 >> POST /search?type=content HTTP/1.1
http-outgoing-0 >> Host: www.zhihu.com
http-outgoing-0 >> "q=java"

因为知乎启动了防爬虫机制,所以此处没有返回200,而是返回了http-outgoing-0 << "HTTP/1.1 301 Moved Permanently[\r][\n]"

解决方案:请求时,带上cookie

2.6 连接池

如果每次请求都要创建HttpClient,会有频繁创建和销毁的问题,可以使用连接池来解决这个问题。测试以下代码,并断点查看每次获取的HttpClient都是不一样的。

public class HttpClientPoolTest {

public static void main(String[] args) {
//创建连接池管理器
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();

//设置最大连接数
cm.setMaxTotal(100);

//设置每个主机的最大连接数
cm.setDefaultMaxPerRoute(10);

//使用连接池管理器发起请求
doGet(cm);
doGet(cm);
}

private static void doGet(PoolingHttpClientConnectionManager cm) {
//不是每次创建新的HttpClient,而是从连接池中获取HttpClient对象
CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(cm).build();

HttpGet httpGet = new HttpGet("http://news.baidu.com");

CloseableHttpResponse response = null;
try {
response = httpClient.execute(httpGet);

if (response.getStatusLine().getStatusCode() == 200) {
String content = EntityUtils.toString(response.getEntity(), "utf8");

System.out.println(content.length());
}

} catch (IOException e) {
e.printStackTrace();
}finally {
if (response != null) {
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
//不能关闭HttpClient,由连接池管理HttpClient
//httpClient.close();
}
}

}
}

2.7 请求参数

有时候因为网络,或者目标服务器的原因,请求需要更长的时间才能完成,我们需要自定义相关时间

public class HttpConfigTest {

public static void main(String[] args) {
//创建HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();

//创建HttpGet对象,设置url访问地址
HttpGet httpGet = new HttpGet("http://news.baidu.com");

//配置请求信息
RequestConfig config = RequestConfig.custom().setConnectTimeout(1000) //创建连接的最长时间,单位是毫秒
.setConnectionRequestTimeout(500) //设置获取连接的最长时间,单位是毫秒
.setSocketTimeout(10*1000) //设置数据传输的最长时间,单位是毫秒
.build();

//给请求设置请求信息
httpGet.setConfig(config);

CloseableHttpResponse response = null;
try {
//使用HttpClient发起请求,获取response
response = httpClient.execute(httpGet);

//解析响应
if (response.getStatusLine().getStatusCode() == 200) {
String content = EntityUtils.toString(response.getEntity(), "utf8");
System.out.println(content.length());
}

} catch (IOException e) {
e.printStackTrace();
}finally {
//关闭response
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
httpClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

3.JSOUP

我们抓取到页面之后,还需要对页面进行解析。可以使用字符串处理工具解析页面,也可以使用正则表达式,但是这些方法都会带来很大的开发成本,所以我们需要使用一款专门解析html页面的技术。

3.1 jsoup介绍

jsoup 是一款Java 的HTML解析器,可直接解析某个URL地址HTML文本内容。它提供了一套非常省力的API,可通过DOM,CSS以及类似于jQuery的操作方法来取出和操作数据。

jsoup的主要功能如下:

  1. 从一个URL,文件或字符串中解析HTML;
  2. 使用DOM或CSS选择器来查找、取出数据;
  3. 可操作HTML元素、属性、文本;

3.2 jsoup解析

我们在刚才项目的pom文件中,加入JSOUP依赖和一些工具类依赖

<!-- https://mvnrepository.com/artifact/org.jsoup/jsoup -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.10.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/junit/junit -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.6</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.7</version>
</dependency>

1 解析URL

Jsoup可以直接输入url,它会发起请求并获取数据,封装为Document对象

   @Test
public void testUrl() throws Exception {
//解析url地址,第一个参数是访问的url,第二个参数是访问时候的超时时间
Document doc = Jsoup.parse(new URL("http://news.baidu.com"), 1000);

//使用标签选择器,获取title标签中的内容
String title = doc.getElementsByTag("title").first().text();

//打印
System.out.println(title);
}
}

PS:虽然使用Jsoup可以替代HttpClient直接发起请求解析数据,但是往往不会这样用,因为实际的开发过程中,需要使用到多线程连接池代理等等方式,而jsoup对这些的支持并不是很好,所以我们一般把jsoup仅仅作为Html解析工具使用

2 解析字符串

先准备以下html文件

<html>
<head>
<title>百度新闻——海量中文资讯平台</title>
</head>
<body>
<div class="city">
<h3 id="city_bj">北京办事处</h3>
<fb:img src="/2018czgw/images/slogan.jpg" class="slogan"/>
<div class="city_in">
<div class="city_con" style="display: none;">
<ul>
<li id="test" class="class_a class_b">
<a href="http://bj.example.com" target="_blank">
<span class="s_name">北京</span>
</a>
</li>
<li>
<a href="http://sh.example.com" target="_blank">
<span class="s_name">上海</span>
</a>
</li>
<li>
<a href="http://gz.example.com" target="_blank">
<span abc="123" class="s_name">广州</span>
</a>
</li>
<ul>
<li>天津</li>
</ul>
</ul>
</div>
</div>
</div>
</body>
</html>

Jsoup可以直接输入字符串,并封装为Document对象

@Test
public void testString() throws Exception {
//使用工具类读取文件,获取字符串
String content = FileUtils.readFileToString(new File("C:\\Users\\tree\\Desktop\\test.html"), "utf8");

//解析字符串
Document doc = Jsoup.parse(content);

String title = doc.getElementsByTag("title").first().text();

System.out.println(title);

}

3 解析文件

Jsoup可以直接解析文件,并封装为Document对象

@Test
public void testFile() throws Exception {
//解析文件
Document doc = Jsoup.parse(new File("C:\\Users\\tree\\Desktop\\test.html"), "utf8");

String title = doc.getElementsByTag("title").first().text();

System.out.println(title);

}

4 使用dom方式遍历文档

获取元素

  1. 根据id查询元素getElementById
  2. 根据标签获取元素getElementsByTag
  3. 根据class获取元素getElementsByClass
  4. 根据属性获取元素getElementsByAttribute
@Test
public void testDOM() throws Exception {
//解析文件,获取Document对象
Document doc = Jsoup.parse(new File("C:\\Users\\tree\\Desktop\\test.html"), "utf8");


//获取元素
//1.根据id查询元素getElementById,输出 北京办事处
Element element = doc.getElementById("city_bj");

//2.根据标签获取元素getElementsByTag 输出 北京
Element element = doc.getElementsByTag("span").first();

//3.根据class获取元素getElementsByClass
Element element = doc.getElementsByClass("class_a class_b").first();
Element element = doc.getElementsByClass("class_a").first();
Element element = doc.getElementsByClass("class_b").first();


//4.根据属性获取元素getElementsByAttribute
//获取属性为abc的值
//Element element = doc.getElementsByAttribute("abc").first();
//第一个属性名,第一个属性值。属性名加属性值进行筛选
Element element = doc.getElementsByAttributeValue("href", "http://gz.example.com").first();

//打印元素的内容
System.out.println("获取到的元素内容是:" + element.text());

}

元素中获取数据

  1. 从元素中获取id
  2. 从元素中获取className
  3. 从元素中获取属性的值attr
  4. 从元素中获取所有属性attributes
  5. 从元素中获取文本内容text
@Test
public void testData() throws Exception {
//解析文件,获取Document
Document doc = Jsoup.parse(new File("C:\\Users\\tree\\Desktop\\test.html"), "utf8");

//根据id获取元素
Element element = doc.getElementById("test");

String str = "";

//元素中获取数据
//1.从元素中获取id
str = element.id();

//2.从元素中获取className
str = element.className();
//Set<String> classSet = element.classNames();
//for (String s : classSet ) {
// System.out.println(s);
//}

//3.从元素中获取属性的值attr
//str = element.attr("id");
str = element.attr("class");

//4.从元素中获取所有属性attributes
Attributes attributes = element.attributes();
System.out.println(attributes.toString());

//5.从元素中获取文本内容text
str = element.text();

//打印获取到的内容
System.out.println("获取到的数据是:" + str);

}

5 使用选择器语法查找元素

jsoup elements对象支持类似于CSS (或jquery)的选择器语法,来实现非常强大和灵活的查找功能。这个select 方法在Document, Element,或Elements对象中都可以使用。且是上下文相关的,因此可实现指定元素的过滤,或者链式选择访问。

Select方法将返回一个Elements集合,并提供一组方法来抽取和处理结果。

6 Selector选择器概述

tagname: 通过标签查找元素,比如:span
#id: 通过ID查找元素,比如:# city_bj
.class: 通过class名称查找元素,比如:.class_a
[attribute]: 利用属性查找元素,比如:[abc]
[attr=value]: 利用属性值来查找元素,比如:[class=s_name]

@Test
public void testSelector() throws Exception {

//解析html文件,获取Document对象
Document doc = Jsoup.parse(new File("C:\\Users\\tree\\Desktop\\test.html"), "utf8");

//tagname: 通过标签查找元素,比如:span
Elements elements = doc.select("span");
//for (Element element : elements) {
// System.out.println(element.text());
//}

//#id: 通过ID查找元素,比如:#city_bj
Element element = doc.select("#city_bj").first();

//.class: 通过class名称查找元素,比如:.class_a
Element element = doc.select(".class_a").first();

//[attribute]: 利用属性查找元素,比如:[abc]
Element element = doc.select("[abc]").first();

//[attr=value]: 利用属性值来查找元素,比如:[class=s_name]
Elements elements1 = doc.select("[class=s_name]");
for (Element element1 : elements1) {
System.out.println(element1.text());
}

//打印结果
System.out.println("获取到的结果是:" + element.text());
}

7 Selector选择器组合使用

el#id: 元素+ID,比如: h3#city_bj
el.class: 元素+class,比如: li.class_a
el[attr]: 元素+属性名,比如: span[abc]
任意组合: 比如:span[abc].s_name
ancestor child: 查找某个元素下子元素,比如:.city_con li 查找”city_con”下的所有li
parent > child: 查找某个父元素下的直接子元素,比如:
.city_con > ul > li: 查找city_con第一级(直接子元素)的ul,再找所有ul下的第一级li
parent > *: 查找某个父元素下所有直接子元素

@Test
public void testSelector2()throws Exception{
//解析html文件,获取Document对象
Document doc = Jsoup.parse(new File("C:\\Users\\tree\\Desktop\\test.html"), "utf8");

//el#id: 元素+ID,比如: h3#city_bj
Element element = doc.select("h3#city_bj").first();

//el.class: 元素+class,比如: li.class_a
element = doc.select("li.class_a").first();

//el[attr]: 元素+属性名,比如: span[abc]
element = doc.select("span[abc]").first();

//任意组合: 比如:span[abc].s_name
element = doc.select("span[abc].s_name").first();

//ancestor child: 查找某个元素下子元素,比如:.city_con li 查找"city_con"下的所有li
Elements elements = doc.select(".city_con li");

//parent > child: 查找某个父元素下的直接子元素,比如:
//.city_con > ul > li 查找city_con第一级(直接子元素)的ul,再找所有ul下的第一级li
elements = doc.select(".city_con > ul > li");

//parent > *: 查找某个父元素下所有直接子元素
elements = doc.select(".city_con > ul > *");


System.out.println("获取到的内容是:"+element.text());

for (Element element1 : elements) {
System.out.println("遍历的结果:"+element1.text());
}
}

3.3 爬虫案例

1.数据库表分析

根据需求分析,我们创建的表如下:

CREATE TABLE `jd_item` (
`id` bigint(10) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`spu` bigint(15) DEFAULT NULL COMMENT '商品集合id',
`sku` bigint(15) DEFAULT NULL COMMENT '商品最小品类单元id',
`title` varchar(100) DEFAULT NULL COMMENT '商品标题',
`price` bigint(10) DEFAULT NULL COMMENT '商品价格',
`pic` varchar(200) DEFAULT NULL COMMENT '商品图片',
`url` varchar(200) DEFAULT NULL COMMENT '商品详情地址',
`created` datetime DEFAULT NULL COMMENT '创建时间',
`updated` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `sku` (`sku`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='京东商品表';

2.添加依赖

本案例使用Spring Boot+Spring Data JPA定时任务进行开发
创建maven项目,并在pom文件中添加如下依赖:

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.2.RELEASE</version>
</parent>
<dependencies>
<!--SpringMVC-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!--SpringData Jpa-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!--MySQL连接包-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<!-- HttpClient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>

<!--Jsoup-->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.10.3</version>
</dependency>

<!--工具包-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>

3.添加配置文件

resources目录下,创建application.properties

spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://118.89.216.120:3306/crawler
spring.datasource.username=root
spring.datasource.password=562644

#JPA Configuration:
spring.jpa.database=MySQL
spring.jpa.show-sql=true

4.编写POJO

根据数据库表,编写pojo

@Entity
@Table(name = "jd_item")
public class Item {
//主键
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
//标准产品单位(商品集合)
private Long spu;
//库存量单位(最小品类单元)
private Long sku;
//商品标题
private String title;
//商品价格
private Double price;
//商品图片
private String pic;
//商品详情地址
private String url;
//创建时间
private Date created;
//更新时间
private Date updated;

//省略get/set方法

5.编写DAO

public interface ItemDao extends JpaRepository<Item,Long> {
}

6.编写Service

ItemService接口

public interface ItemService {

/**
* 保存商品
* @param item
*/
public void save(Item item);

/**
* 根据条件查询商品
* @param item
* @return
*/
public List<Item> findAll(Item item);
}

ItemService接口实现类

@Service
public class ItemServiceImpl implements ItemService {

@Autowired
private ItemDao itemDao;

@Override
@Transactional
public void save(Item item) {
this.itemDao.save(item);
}

@Override
public List<Item> findAll(Item item) {
//声明查询条件
Example<Item> example = Example.of(item);

//根据查询条件进行查询数据
List<Item> list = this.itemDao.findAll(example);

return list;
}
}

7.编写引导类

@SpringBootApplication
//使用定时任务,需要先开启定时任务,需要添加注解
@EnableScheduling
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

8.封装HttpClient

我们需要经常使用HttpClient,所以需要进行封装,方便使用

@Component
public class HttpUtils {

private PoolingHttpClientConnectionManager cm;

public HttpUtils() {
this.cm = new PoolingHttpClientConnectionManager();

//设置最大连接数
this.cm.setMaxTotal(100);

//设置每个主机的最大连接数
this.cm.setDefaultMaxPerRoute(10);
}

/**
* 根据请求地址下载页面数据
*
* @param url
* @return 页面数据
*/
public String doGetHtml(String url) {
//获取HttpClient对象
CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(this.cm).build();

//创建httpGet请求对象,设置url地址
HttpGet httpGet = new HttpGet(url);

//设置请求信息
httpGet.setConfig(this.getConfig());
httpGet.addHeader("User-Agent"," Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36");
httpGet.addHeader("Content-Type","text/html");

CloseableHttpResponse response = null;


try {
//使用HttpClient发起请求,获取响应
response = httpClient.execute(httpGet);

//解析响应,返回结果
if (response.getStatusLine().getStatusCode() == 200) {
//判断响应体Entity是否不为空,如果不为空就可以使用EntityUtils
if (response.getEntity() != null) {
String content = EntityUtils.toString(response.getEntity(), "utf8");
return content;
}
}

} catch (IOException e) {
e.printStackTrace();
} finally {
//关闭response
if (response != null) {
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//返回空串
return "";
}


/**
* 下载图片
*
* @param url
* @return 图片名称
*/
public String doGetImage(String url) {
//获取HttpClient对象
CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(this.cm).build();

//创建httpGet请求对象,设置url地址
HttpGet httpGet = new HttpGet(url);

//设置请求信息
httpGet.setConfig(this.getConfig());

CloseableHttpResponse response = null;


try {
//使用HttpClient发起请求,获取响应
response = httpClient.execute(httpGet);

//解析响应,返回结果
if (response.getStatusLine().getStatusCode() == 200) {
//判断响应体Entity是否不为空
if (response.getEntity() != null) {
//下载图片
//获取图片的后缀
String extName = url.substring(url.lastIndexOf("."));

//创建图片名,重命名图片
String picName = UUID.randomUUID().toString() + extName;

//下载图片
//声明OutPutStream
OutputStream outputStream = new FileOutputStream(new File("C:\\Users\\tree\\Desktop\\images\\" +
picName));
response.getEntity().writeTo(outputStream);

//返回图片名称
return picName;
}
}

} catch (IOException e) {
e.printStackTrace();
} finally {
//关闭response
if (response != null) {
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//如果下载失败,返回空串
return "";
}

//设置请求信息
private RequestConfig getConfig() {
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(1000) //创建连接的最长时间
.setConnectionRequestTimeout(500) // 获取连接的最长时间
.setSocketTimeout(10000) //数据传输的最长时间
.build();

return config;
}
}

9.实现数据抓取

使用定时任务,可以定时抓取最新的数据

@Component //创建实例对象
public class ItemTask {

@Autowired
private HttpUtils httpUtils;
@Autowired
private ItemService itemService;

//解析json工具类
private static final ObjectMapper MAPPER = new ObjectMapper();


//当下载任务完成后,间隔多长时间进行下一次的任务。
//@Scheduled(fixedDelay = 100 * 1000)
public void itemTask() throws Exception {
//声明需要解析的初始地址
String url = "https://search.jd.com/Search?keyword=%E6%89%8B%E6%9C%BA&enc=utf-8&qrst=1&rt=1&stop=1&vt=2&wq" +
"=%E6%89%8B%E6%9C%BA&cid2=653&cid3=655&s=113&click=0&page=";

//按照页面对手机的搜索结果进行遍历解析
for (int i = 1; i < 10; i = i + 2) {
String html = httpUtils.doGetHtml(url + i);
//解析页面,获取商品数据并存储
this.parse(html);
}


System.out.println("手机数据抓取完成!");


}

//解析页面,获取商品数据并存储
private void parse(String html) throws Exception {
//解析html获取Document
Document doc = Jsoup.parse(html);

//获取spu信息
Elements spuEles = doc.select("div#J_goodsList > ul > li"); //li下全部子元素

for (Element spuEle : spuEles) {
//获取spu 通过获取属性名获取spu
long spu = Long.parseLong(spuEle.attr("data-spu"));

//获取sku信息
Elements skuEles = spuEle.select("li.ps-item");

for (Element skuEle : skuEles) {
//获取sku
long sku = Long.parseLong(skuEle.select("[data-sku]").attr("data-sku"));

//根据sku查询商品数据
Item item = new Item();
item.setSku(sku);
List<Item> list = this.itemService.findAll(item);

if(list.size()>0) {
//如果商品存在,就进行下一个循环,该商品不保存,因为已存在
continue;
}

//设置商品的spu
item.setSpu(spu);

//获取商品的详情的url
String itemUrl = "https://item.jd.com/" + sku + ".html";
item.setUrl(itemUrl);


//获取商品的图片
String picUrl ="https:"+ skuEle.select("img[data-sku]").first().attr("data-lazy-img");
picUrl = picUrl.replace("/n9/","/n1/");
String picName = this.httpUtils.doGetImage(picUrl);
item.setPic(picName);

//获取商品的价格
String priceJson = this.httpUtils.doGetHtml("https://p.3.cn/prices/mgets?skuIds=J_" + sku);
double price = MAPPER.readTree(priceJson).get(0).get("p").asDouble();
item.setPrice(price);


//获取商品的标题
String itemInfo = this.httpUtils.doGetHtml(item.getUrl());
String title = Jsoup.parse(itemInfo).select("div.sku-name").text();
item.setTitle(title);


item.setCreated(new Date());
item.setUpdated(item.getCreated());

//保存商品数据到数据库中
this.itemService.save(item);

}
}
}

}

4.WebMagic

官网:http://webmagic.io/

4.1 WebMagic介绍

WebMagic项目代码分为核心和扩展两部分。核心部分(webmagic-core)是一个精简的、模块化的爬虫实现,而扩展部分则包括一些便利的、实用性的功能。

WebMagic的设计目标是尽量的模块化,并体现爬虫的功能特点。这部分提供非常简单、灵活的API,在基本不改变开发模式的情况下,编写一个爬虫。

扩展部分(webmagic-extension)提供一些便捷的功能,例如注解模式编写爬虫等。同时内置了一些常用的组件,便于爬虫开发。

WebMagic底层是HttpClient和Jsoup,让我们能够更方便的开发爬虫。

4.2 架构介绍

WebMagic的结构分为DownloaderPageProcessorScheduler、Pipeline四大组件,并由Spider将它们彼此组织起来。这四大组件对应爬虫生命周期中的下载、处理、管理和持久化等功能。WebMagic的设计参考了Scapy,但是实现方式更Java化一些。

而Spider则将这几个组件组织起来,让它们可以互相交互,流程化的执行,可以认为Spider是一个大的容器,它也是WebMagic逻辑的核心。

WebMagic总体架构图如下:

1.WebMagic的四个组件

1.Downloader
Downloader负责从互联网上下载页面,以便后续处理。WebMagic默认使用了Apache HttpClient作为下载工具。

2.PageProcessor
PageProcessor负责解析页面,抽取有用信息,以及发现新的链接。WebMagic使用Jsoup作为HTML解析工具,并基于其开发了解析XPath的工具Xsoup

在这四个组件中,PageProcessor对于每个站点每个页面都不一样,是需要使用者定制的部分

3.Scheduler
Scheduler负责管理待抓取的URL,以及一些去重的工作。WebMagic默认提供了JDK的内存队列来管理URL,并用集合来进行去重。也支持使用Redis进行分布式管理。

4.Pipeline
Pipeline负责抽取结果的处理,包括计算、持久化到文件、数据库等。WebMagic默认提供了“输出到控制台”和“保存到文件”两种结果处理方案。

Pipeline定义了结果保存的方式,如果你要保存到指定数据库,则需要编写对应的Pipeline。对于一类需求一般只需编写一个Pipeline。

2.用于数据流转的对象

1.Request
Request是对URL地址的一层封装,一个Request对应一个URL地址。

它是PageProcessor与Downloader交互的载体,也是PageProcessor控制Downloader唯一方式。

除了URL本身外,它还包含一个Key-Value结构的字段extra。你可以在extra中保存一些特殊的属性,然后在其他地方读取,以完成不同的功能。例如附加上一个页面的一些信息等。

2.Page
Page代表了从Downloader下载到的一个页面——可能是HTML,也可能是JSON或者其他文本格式的内容。

Page是WebMagic抽取过程的核心对象,它提供一些方法可供抽取、结果保存等。

3.ResultItems
ResultItems相当于一个Map,它保存PageProcessor处理的结果,供Pipeline使用。它的API与Map很类似,值得注意的是它有一个字段skip,若设置为true,则不应被Pipeline处理。

4.3实现PageProcessor

1.入门案例

创建Maven工程,并加入以下依赖

<dependencies>
<!-- https://mvnrepository.com/artifact/us.codecraft/webmagic-core -->
<!--核心包-->
<dependency>
<groupId>us.codecraft</groupId>
<artifactId>webmagic-core</artifactId>
<version>0.7.3</version>
</dependency>
<!-- https://mvnrepository.com/artifact/us.codecraft/webmagic-extension -->
<!--扩展包-->
<dependency>
<groupId>us.codecraft</groupId>
<artifactId>webmagic-extension</artifactId>
<version>0.7.3</version>
</dependency>
<!--WebMagic对布隆过滤器的支持-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0</version>
</dependency>
</dependencies>

注意:0.7.3版本的核心包对SSL的并不完全,如果是直接从Maven中央仓库下载依赖,在爬取只支持SSL v1.2的网站会有SSL的异常抛出。

解决方案:

  1. 等作者的0.7.4的版本发布
  2. 直接从github上下载最新的代码,安装到本地仓库

也可以参考以下资料自己修复:https://github.com/code4craft/webmagic/issues/701

加入配置文件:WebMagic使用slf4j-log4j12作为slf4j的实现,添加WebMagic依赖的同时log4j的依赖也自动添加过来了,所以无需手动添加。
添加log4j.properties配置文件

log4j.rootLogger=INFO,A1 

log4j.appender.A1=org.apache.log4j.ConsoleAppender
log4j.appender.A1.layout=org.apache.log4j.PatternLayout
log4j.appender.A1.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss,SSS} [%t] [%c]-[%p] %m%n

测试类:

//实现解析组件 PageProcessor
public class JobProcessor implements PageProcessor {

//解析页面
public void process(Page page) {
//获取html中的div标签,同时class为mt下的h2标签
//解析返回的数据page,并且把解析的结果放到ResultItems中
//前面是key,可以随便写。后面是value。此处用的是select选择器方式
page.putField("div", page.getHtml().css("div.mt>h2").all()); //all,获取所有符合条件的
}

private Site site = Site.me();

public Site getSite() {
return site;
}

//执行入口
public static void main(String[] args) {
Spider.create(new JobProcessor()) //创建自己定义的解析器
//初始访问url地址
.addUrl("https://www.jd.com/moreSubject.aspx") //设置爬取数据页面的地址
.run(); //执行爬虫
}
}

注:如果不写,日志默认输出到控制台

2.抽取元素Selectable

WebMagic里主要使用了三种抽取技术:XPath正则表达式CSS选择器。另外,对于JSON格式的内容,可使用JsonPath进行解析。
1.XPath

XPath语法参考:https://www.w3school.com.cn/xpath/xpath_syntax.asp

示例:

//获取div标签,并且div的id等于news_div,该div下面的ul标签,ul标签下的li标签,li下的div标签,div标签下的a标签的所有内容
// "//"从匹配选择的当前节点选择文档中的节点,而不考虑它们的位置。
page.putField("div2", page.getHtml().xpath("//div[@id=news_div]/ul/li/div/a"));

也可以参考W3School手册

2.CSS选择器
CSS选择器是与XPath类似的语言。在上一次的课程中,我们已经学习过了Jsoup的选择器,它比XPath写起来要简单一些,但是如果写复杂一点的抽取规则,就相对要麻烦一点。

示例:

//获取html中的div标签,并且div标签的class等于mt下的,所有h2标签
page.putField("div", page.getHtml().css("div.mt>h2").all()); //all,获取所有符合条件的

使用:nth-child(n)选择第几个元素,如下选择第一个元素

//获取html中的div标签,并且div标签的id等于news_div,div下的ul标签,ul下第一个li的a标签内容
page.putField("div",page.getHtml().css("div#news_div > ul > li:nth-child(1) a").toString());

注意:需要使用>,就是直接子元素才可以选择第几个元素

3.正则表达式
正则表达式则是一种通用的文本抽取语言。在这里一般用于获取url地址。
语法参考:https://www.runoob.com/regexp/regexp-syntax.html

示例:

//获取html中的div标签,并且div标签的id等于news_div,包含江苏的所有a标签。江苏前后不做限制。
page.putField("div3", page.getHtml().css("div#news_div a").regex(".*江苏.*").all());

3.抽取元素Selectable对应API

Selectable相关的抽取元素链式API是WebMagic的一个核心功能。使用Selectable接口,可以直接完成页面元素的链式抽取,也无需去关心抽取的细节。
在刚才的例子中可以看到,page.getHtml()返回的是一个Html对象,它实现了Selectable接口。这个接口包含的方法分为两类:抽取部分和获取结果部分。

方法 说明 示例
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("\(.\*?)\")

4.获取结果API

当链式调用结束时,我们一般都想要拿到一个字符串类型的结果。这时候就需要用到获取结果的API了。

我们知道,一条抽取规则,无论是XPath、CSS选择器或者正则表达式,总有可能抽取到多条元素。WebMagic对这些进行了统一,可以通过不同的API获取到一个或者多个元素。
获取一条结果,默认是获取第一条。

方法 说明 示例
get() 返回一条String类型的结果 String link= html.links().get())
toString() 同get(),返回一条String类型的结果 String link= html.links().toString()
all() 返回所有抽取结果 List links= html.links().all())

示例:

//获取结果API
//获取html中的div标签,并且div标签的id等于news_div,包含江苏的所有a标签。返回一条String类型结果
page.putField("div4", page.getHtml().css("div#news_div a").regex(".*江苏.*").get());
page.putField("div5", page.getHtml().css("div#news_div a").regex(".*江苏.*").toString());

这里selectable.toString()采用了toString()这个接口,是为了在输出以及和一些框架结合的时候,更加方便。因为一般情况下,我们都只需要选择一个元素!
selectable.all()则会获取到所有元素。

5.获取链接

有了处理页面的逻辑,我们的爬虫就接近完工了,但是现在还有一个问题:一个站点的页面是很多的,一开始我们不可能全部列举出来,于是如何发现后续的链接,是一个爬虫不可缺少的一部分。

示例:

//分两步操作
//1.添加地址,先抓取链接
//获取html中的div标签,并且div标签的id等于news_div下所有以9结尾的超链接元素。
//其实不加a也可以。因为div.links()方法会默认找到该div所有的超链接,所以这里我们去掉了a标签
page.addTargetRequests(page.getHtml().css("div#news_div").links().regex(".*9$").all());
//2.输出所有h1标题
//获取html中的div标签,并且div标签的class等于mt下,所有h1元素内容。
page.putField("url",page.getHtml().css("div.mt h1").all());

执行程序。可以发现程序是先发送请求,后获取内容。

4.4使用Pipeline保存结果

WebMagic用于保存结果的组件叫做Pipeline。我们现在通过“控制台输出结果”这件事也是通过一个内置的Pipeline完成的,它叫做ConsolePipeline。

那么,我现在想要把结果用保存到文件中,怎么做呢?只将Pipeline的实现换成”FilePipeline”就可以了。
修改4.3.1入门案例中的主方法,增加addPipeline()方法

示例:

   //主函数,执行爬虫
public static void main(String[] args) {
Spider.create(new JobProcessor())
//初始访问url地址
.addUrl("https://www.jd.com/moreSubject.aspx") //设置爬取数据的页面地址
.addPipeline(new FilePipeline("D:/webmagic/")) //输出到文件中
.thread(5)//设置5个线程
.run(); //执行爬虫
}

4.5爬虫的配置、启动和终止

1.Spider

Spider是爬虫启动的入口。在启动爬虫之前,我们需要使用一个PageProcessor创建一个Spider对象,然后使用run()进行启动。
同时Spider的其他组件(Downloader、Scheduler、Pipeline)都可以通过set方法来进行设置。

方法 说明 示例
create(PageProcessor) 创建Spider Spider.create(new GithubRepoProcessor())
addUrl(String…) 添加初始的URL spider .addUrl("http://webmagic.io/docs/")
thread(n) 开启n个线程 spider.thread(n)
run() 启动,会阻塞当前线程执行 spider.run()
start()/runAsync() 异步启动,当前线程继续执行 spider.start()
stop() 停止爬虫 spider.stop()
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<ResultItems> results = spider .getAll("http://webmagic.io/docs/", "http://webmagic.io/xxx")

2.爬虫配置Site

Site.me()可以对爬虫进行一些配置配置,包括编码、抓取间隔、超时时间、重试次数等。在这里我们先简单设置一下:重试次数为3次,抓取间隔为一秒。

private Site site = Site.me()
.setCharset("utf8") //设置编码
.setTimeOut(10000) //设置超时时间,单位是ms毫秒 10s
.setRetrySleepTime(3000) //设置重试的间隔时间
.setSleepTime(3) //设置重试次数
;

站点本身的一些配置信息,例如编码、HTTP头、超时时间、重试策略等、代理等,都可以通过设置Site对象来进行配置。

方法 说明 示例
setCharset(String) 设置编码 site.setCharset("utf-8")
setUserAgent(String) 设置UserAgent site.setUserAgent("Spider")
setTimeOut(int) 设置超时时间,单位是毫秒 site.setTimeOut(3000)
setRetryTimes(int) 设置重试次数 site.setRetryTimes(3))
setCycleRetryTimes(int) 设置循环重试次数 site.setCycleRetryTimes(3)
addCookie(String,String) 添加一条cookie site.addCookie("dotcomt_user","code4craft")
setDomain(String) 设置域名,需设置域名后,addCookie才可生效 site.setDomain("github.com")
addHeader(String,String) 添加一条addHeader site.addHeader("Referer","https://github.com")
setHttpProxy(HttpHost) 设置Http代理 site.setHttpProxy(new HttpHost("127.0.0.1",8080))

4.6 测试代码参考

public class JobProcessor implements PageProcessor {

//解析页面
public void process(Page page) {
//解析返回的数据page,并且把解析的结果放到ResultItems中
//css选择器
page.putField("div", page.getHtml().css("div.mt h2").all());

//XPath
page.putField("div2", page.getHtml().xpath("//div[@id=news_div]/ul/li/div/a"));

//正则表达式
page.putField("div3", page.getHtml().css("div#news_div a").regex(".*江苏.*").all());

//处理结果API
page.putField("div4", page.getHtml().css("div#news_div a").regex(".*江苏.*").get());
page.putField("div5", page.getHtml().css("div#news_div a").regex(".*江苏.*").toString());

//获取链接
//page.addTargetRequests(page.getHtml().css("div#news_div").links().regex(".*9$").all());
//page.putField("url",page.getHtml().css("div.mt h1").all());

page.addTargetRequest("https://www.jd.com/news.html?id=37319");
page.addTargetRequest("https://www.jd.com/news.html?id=37319");
page.addTargetRequest("https://www.jd.com/news.html?id=37319");
}


private Site site = Site.me()
.setCharset("utf8") //设置编码
.setTimeOut(10000) //设置超时时间,单位是ms毫秒
.setRetrySleepTime(3000) //设置重试的间隔时间
.setSleepTime(3) //设置重试次数
;

public Site getSite() {
return site;
}

//主函数,执行爬虫
public static void main(String[] args) {
Spider spider = Spider.create(new JobProcessor())
.addUrl("https://www.jd.com/moreSubject.aspx") //设置爬取数据的页面
//.addPipeline(new FilePipeline("C:\\Users\\tree\\Desktop\\result"))
.thread(5)
.setScheduler(new QueueScheduler().setDuplicateRemover(new BloomFilterDuplicateRemover(10000000)));//设置布隆去重过滤器,指定最多对1000万数据进行去重操作
Scheduler scheduler = spider.getScheduler();

//执行爬虫
spider.run();
}
}

5.案例分析

我们已经学完了WebMagic的基本使用方法,现在准备使用WebMagic实现爬取数据的功能。这里是一个比较完整的实现。
在这里我们实现的是聚焦网络爬虫,只爬取招聘的相关数据。

5.1 业务分析

今天要实现的是爬取 https://www.51job.com/ 上的招聘信息。只爬取“计算机软件”和“互联网电子商务”两个行业的信息。

首先访问页面并搜索两个行业。结果如下

点击职位详情页,我们分析发现详情页还有一些数据需要抓取:
职位、公司名称、工作地点、薪资、发布时间、职位信息、公司联系方式、公司信息

5.2 数据库表

根据以上信息,设计数据库表:

CREATE TABLE `job_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`company_name` varchar(100) DEFAULT NULL COMMENT '公司名称',
`company_addr` varchar(200) DEFAULT NULL COMMENT '公司联系方式',
`company_info` text COMMENT '公司信息',
`job_name` varchar(100) DEFAULT NULL COMMENT '职位名称',
`job_addr` varchar(50) DEFAULT NULL COMMENT '工作地点',
`job_info` text COMMENT '职位信息',
`salary_min` int(10) DEFAULT NULL COMMENT '薪资范围,最小',
`salary_max` int(10) DEFAULT NULL COMMENT '薪资范围,最大',
`url` varchar(150) DEFAULT NULL COMMENT '招聘信息详情页',
`time` varchar(10) DEFAULT NULL COMMENT '职位最近发布时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='招聘信息';

5.3 实现流程

我们需要解析职位列表页,获取职位的详情页,再解析页面获取数据。
获取url地址的流程如下:

但是在这里有个问题:在解析页面的时候,很可能会解析出相同的url地址(例如商品标题和商品图片超链接,而且url一样),如果不进行处理,同样的url会解析处理多次,浪费资源。所以我们需要有一个url去重的功能

1.Scheduler组件

WebMagic提供了Scheduler可以帮助我们解决以上问题。
Scheduler是WebMagic中进行URL管理的组件。一般来说,Scheduler包括两个作用:

  • 对待抓取的URL队列进行管理。
  • 对已抓取的URL进行去重

WebMagic内置了几个常用的Scheduler。如果你只是在本地执行规模比较小的爬虫,那么基本无需定制Scheduler,但是了解一下已经提供的几个Scheduler还是有意义的。

说明 备注
DuplicateRemovedScheduler 抽象基类,提供一些模板方法 继承它可以实现自己的功能
QueueScheduler 使用内存队列保存待抓取URL
PriorityScheduler 使用带有优先级的内存队列保存待抓取URL 耗费内存较QueueScheduler更大,但是当设置了request.priority之后,只能使用PriorityScheduler才可使优先级生效
FileCacheQueueScheduler 使用文件保存抓取URL,可以在关闭程序并下次启动时,从之前抓取到的URL继续抓取 需指定路径,会建立.urls.txt和.cursor.txt两个文件
RedisScheduler 使用Redis保存抓取队列,可进行多台机器同时合作抓取 需要安装并启动redis

去重部分被单独抽象成了一个接口:DuplicateRemover,从而可以为同一个Scheduler选择不同的去重方式,以适应不同的需要,目前提供了两种去重方式。

说明
HashSetDuplicateRemover 使用HashSet来进行去重,占用内存较大
BloomFilterDuplicateRemover 使用BloomFilter来进行去重,占用内存较小,但是可能漏抓页面

RedisScheduler是使用Redis的set进行去重,其他的Scheduler默认都使用HashSetDuplicateRemover来进行去重。

RedisScheduler是使用Redis的set进行去重,其他的Scheduler默认都使用HashSetDuplicateRemover来进行去重。
如果要使用BloomFilter,必须要加入以下依赖:

<!--WebMagic对布隆过滤器的支持-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0</version>
</dependency>

2.三种去重方式

去重就有三种实现方式,那有什么不同呢?

HashSet:使用java中的HashSet不能重复的特点去重。优点:是容易理解。使用方便。缺点:占用内存大,性能较低。默认去重方式

示例:

public class JobProcessor implements PageProcessor {

//解析页面
public void process(Page page) {
//设置相同的三个链接
page.addTargetRequest("https://www.jd.com/news.html?id=37319");
page.addTargetRequest("https://www.jd.com/news.html?id=37319");
page.addTargetRequest("https://www.jd.com/news.html?id=37319");
}


private Site site = Site.me()
.setCharset("utf8") //设置编码
.setTimeOut(10000) //设置超时时间,单位是ms毫秒
.setRetrySleepTime(3000) //设置重试的间隔时间
.setSleepTime(3) //设置重试次数
;

public Site getSite() {
return site;
}

//主函数,执行爬虫
public static void main(String[] args) {
Spider spider = Spider.create(new JobProcessor())
.addUrl("https://www.jd.com/moreSubject.aspx") //设置爬取数据的页面
.thread(5);

Scheduler scheduler = spider.getScheduler();

//执行爬虫
spider.run();
}
}

布隆过滤器(BloomFilter)
使用布隆过滤器也可以实现去重。优点是占用的内存要比使用HashSet要小的多,也适合大量数据的去重操作。
缺点:有误判的可能。没有重复可能会判定重复,但是重复数据一定会判定重复。

示例:

需要在pom文件中添加依赖:

```xml
<!--WebMagic对布隆过滤器的支持-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0</version>
</dependency>

测试方法:

public class JobProcessor implements PageProcessor {

//解析页面
public void process(Page page) {
//设置相同的三个链接
page.addTargetRequest("https://www.jd.com/news.html?id=37319");
page.addTargetRequest("https://www.jd.com/news.html?id=37319");
page.addTargetRequest("https://www.jd.com/news.html?id=37319");
}


private Site site = Site.me()
.setCharset("utf8") //设置编码
.setTimeOut(10000) //设置超时时间,单位是ms毫秒
.setRetrySleepTime(3000) //设置重试的间隔时间
.setSleepTime(3) //设置重试次数
;

public Site getSite() {
return site;
}

//主函数,执行爬虫
public static void main(String[] args) {
Spider spider = Spider.create(new JobProcessor())
.addUrl("https://www.jd.com/moreSubject.aspx") //设置爬取数据的页面
.thread(5)
.setScheduler(new QueueScheduler().setDuplicateRemover(new BloomFilterDuplicateRemover(10000000)));//设置布隆去重过滤器,指定最多对1000万数据进行去重操作

Scheduler scheduler = spider.getScheduler();

//执行爬虫
spider.run();
}
}

Redis去重
使用Redis的set进行去重。优点是速度快(Redis本身速度就很快),而且去重不会占用爬虫服务器的资源,可以处理更大数据量的数据爬取。
缺点:需要准备Redis服务器,增加开发和使用成本。

3.布隆过滤器介绍与实现(了解)

介绍
布隆过滤器 (Bloom Filter)是由Burton Howard Bloom于1970年提出,它是一种space efficient的概率型数据结构,用于判断一个元素是否在集合中。在垃圾邮件过滤的黑白名单方法、爬虫(Crawler)的网址判重模块中等等经常被用到。
哈希表也能用于判断元素是否在集合中,但是布隆过滤器只需要哈希表的1/8或1/4的空间复杂度就能完成同样的问题。布隆过滤器可以插入元素,但不可以删除已有元素。其中的元素越多,误报率越大,但是漏报是不可能的。
原理
布隆过滤器需要的是一个位数组(和位图类似)和K个映射函数(和Hash表类似),在初始状态时,对于长度为m的位数组array,它的所有位被置0。

对于有n个元素的集合S={S1,S2...Sn},通过k个映射函数{f1,f2,......fk},将集合S中的每个元素Sj(1<=j<=n)映射为K个值{g1,g2...gk},然后再将位数组array中相对应的array[g1],array[g2]......array[gk]置为1:

如果要查找某个元素item是否在S中,则通过映射函数{f1,f2,...fk}得到k个值{g1,g2...gk},然后再判断array[g1],array[g2]...array[gk]是否都为1,若全为1,则item在S中,否则item不在S中。

布隆过滤器会造成一定的误判,因为集合中的若干个元素通过映射之后得到的数值恰巧包括g1,g2,…gk,在这种情况下可能会造成误判,但是概率很小。

实现

以下是一个布隆过滤器的实现,可以参考:

public class BloomFilter {

/* BitSet初始分配2^24个bit */
private static final int DEFAULT_SIZE = 1 << 24;

/* 不同哈希函数的种子,一般应取质数 */
private static final int[] seeds = new int[]{5, 7, 11, 13, 31, 37};

private BitSet bits = new BitSet(DEFAULT_SIZE);

/* 哈希函数对象 */
private SimpleHash[] func = new SimpleHash[seeds.length];

public BloomFilter() {
for (int i = 0; i < seeds.length; i++) {
func[i] = new SimpleHash(DEFAULT_SIZE, seeds[i]);
}
}

// 将url标记到bits中
public void add(String str) {
for (SimpleHash f : func) {
bits.set(f.hash(str), true);
}
}

// 判断是否已经被bits标记
public boolean contains(String str) {
if (StringUtils.isBlank(str)) {
return false;
}

boolean ret = true;
for (SimpleHash f : func) {
ret = ret && bits.get(f.hash(str));
}

return ret;
}

/* 哈希函数类 */
public static class SimpleHash {
private int cap;
private int seed;

public SimpleHash(int cap, int seed) {
this.cap = cap;
this.seed = seed;
}

// hash函数,采用简单的加权和hash
public int hash(String value) {
int result = 0;
int len = value.length();
for (int i = 0; i < len; i++) {
result = seed * result + value.charAt(i);
}
return (cap - 1) & result;
}
}
}

6.案例实现

6.1 开发准备

创建Maven工程,并加入依赖。pom.xml为:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.2.RELEASE</version>
</parent>

<groupId>cn.crawler</groupId>
<artifactId>cn-crawler-job</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<!--SpringMVC-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!--SpringData Jpa-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!--MySQL连接包-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<!--WebMagic核心包-->
<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>
<!--WebMagic扩展-->
<dependency>
<groupId>us.codecraft</groupId>
<artifactId>webmagic-extension</artifactId>
<version>0.7.3</version>
</dependency>

<!--WebMagic对布隆过滤器的支持-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0</version>
</dependency>

<!--工具包-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>

<!--ElasticSearch-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

<!--单元测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>


<!--simhash-->
<dependency>
<groupId>com.lou</groupId>
<artifactId>simhasher</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
</exclusion>
</exclusions>

</dependency>
</dependencies>
</project>

resources目录下,添加application.properties配置文件

#DB Configuration:
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/crawler
spring.datasource.username=root
spring.datasource.password=root

#JPA Configuration:
spring.jpa.database=MySQL
spring.jpa.show-sql=true

resources目录下,增加日志文件log4j.properties:

log4j.rootLogger=INFO,A1 

log4j.appender.A1=org.apache.log4j.ConsoleAppender
log4j.appender.A1.layout=org.apache.log4j.PatternLayout
log4j.appender.A1.layout.ConversionPattern=%-d{yyyy-MM-dd HH:mm:ss,SSS} [%t] [%c]-[%p] %m%n

编写Pojo:

@Entity
public class JobInfo {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String companyName;
private String companyAddr;
private String companyInfo;
private String jobName;
private String jobAddr;
private String jobInfo;
private Integer salaryMin;
private Integer salaryMax;
private String url;
private String time;

//省略get/set tostring方法

编写Dao:

public interface JobInfoDao extends JpaRepository<JobInfo, Long> {
}

编写Service:

public interface JobInfoService {

/**
* 保存工作信息
*
* @param jobInfo
*/
public void save(JobInfo jobInfo);


/**
* 根据条件查询工作信息
*
* @param jobInfo
* @return
*/
public List<JobInfo> findJobInfo(JobInfo jobInfo);
}

编写Service实现类:

@Service
public class JobInfoServiceImpl implements JobInfoService {

@Autowired
private JobInfoDao jobInfoDao;


@Override
@Transactional
public void save(JobInfo jobInfo) {
//根据url和发布时间查询数据
JobInfo param = new JobInfo();
param.setUrl(jobInfo.getUrl());
param.setTime(jobInfo.getTime());

//执行查询
List<JobInfo> list = this.findJobInfo(param);


//判断查询结果是否为空
if (list.size() == 0) {
//如果查询结果为空,表示招聘信息数据不存在,或者已经更新了,需要新增或者更新数据库
this.jobInfoDao.saveAndFlush(jobInfo);
}
}

@Override
public List<JobInfo> findJobInfo(JobInfo jobInfo) {

//设置查询条件
Example example = Example.of(jobInfo);

//执行查询
List list = this.jobInfoDao.findAll(example);

return list;
}
}

编写引导类:

@SpringBootApplication
@EnableScheduling//开启定时任务
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

6.2 功能实现

创建一个包名为task的包,

创建工具类,处理薪资:

public class MathSalary {


/**
* 获取薪水范围
*
* @param salaryStr
* @return
*/
public static Integer[] getSalary(String salaryStr) {
//声明存放薪水范围的数组
Integer[] salary = new Integer[2];

//"500/天"
//0.8-1.2万/月
//5-8千/月
//5-6万/年
String date = salaryStr.substring(salaryStr.length() - 1, salaryStr.length());
//如果是按天,则直接乘以240进行计算
if (!"月".equals(date) && !"年".equals(date)) {
salaryStr = salaryStr.substring(0, salaryStr.length() - 2);
salary[0] = salary[1] = str2Num(salaryStr, 240);
return salary;
}

String unit = salaryStr.substring(salaryStr.length() - 3, salaryStr.length() - 2);
String[] salarys = salaryStr.substring(0, salaryStr.length() - 3).split("-");


salary[0] = mathSalary(date, unit, salarys[0]);
salary[1] = mathSalary(date, unit, salarys[1]);

return salary;


}

//根据条件计算薪水
private static Integer mathSalary(String date, String unit, String salaryStr) {
Integer salary = 0;

//判断单位是否是万
if ("万".equals(unit)) {
//如果是万,薪水乘以10000
salary = str2Num(salaryStr, 10000);
} else {
//否则乘以1000
salary = str2Num(salaryStr, 1000);
}

//判断时间是否是月
if ("月".equals(date)) {
//如果是月,薪水乘以12
salary = str2Num(salary.toString(), 12);
}

return salary;
}


private static int str2Num(String salaryStr, int num) {
try {
// 把字符串转为小数,必须用Number接受,否则会有精度丢失的问题
Number result = Float.parseFloat(salaryStr) * num;
return result.intValue();
} catch (Exception e) {
}
return 0;
}
}

编写url解析功能:

@Component
public class JobProcessor implements PageProcessor {

private String url = "https://search.51job.com/list/000000,000000,0000,32%252C01,9,99,java,2," +
"1.html?lang=c&stype=&postchannel=0000&workyear=99&cotype=99&degreefrom=99&jobterm=99&companysize=99" +
"&providesalary=99&lonlat=0%2C0&radius=-1&ord_field=0&confirmdate=9&fromType=&dibiaoid=0&address=&line" +
"=&specialarea=00&from=&welfare=";

@Override
public void process(Page page) {
//解析页面,获取招聘信息详情的url地址
//获取html中的div标签,并且div标签的id等于resultList下,div的class等于el的标签。
List<Selectable> list = page.getHtml().css("div#resultList div.el").nodes(); //nodes 获取选择器的所有节点


//判断获取到的集合是否为空
if (list.size() == 0) {
// 如果为空,表示这是招聘详情页,解析页面,获取招聘详情信息,保存数据
this.saveJobInfo(page);

} else {
//如果不为空,表示这是列表页,解析出详情页的url地址,放到任务队列中
for (Selectable selectable : list) {
//获取url地址
String jobInfoUrl = selectable.links().toString();
//把获取到的url地址放到任务队列中
page.addTargetRequest(jobInfoUrl);
}

//获取下一页的url
String bkUrl = page.getHtml().css("div.p_in li.bk").nodes().get(1).links().toString(); //一共两条符合数据,第一条是上一页超链接,第二条是下一页超链接。0代表第一条,1代表第二条
//把url放到任务队列中
page.addTargetRequest(bkUrl);

}


String html = page.getHtml().toString();


}

//解析页面,获取招聘详情信息,保存数据
private void saveJobInfo(Page page) {
//创建招聘详情对象
JobInfo jobInfo = new JobInfo();

//解析页面
Html html = page.getHtml();

//获取数据,封装到对象中
//获取html中的div标签,并且div标签的id等于cn,并找到div下的p标签的class等于cname的P标签,找到该p标签对应的a标签内容
jobInfo.setCompanyName(html.css("div.cn p.cname a","text").toString()); //唯一的值,可以用webmagic
jobInfo.setCompanyAddr(Jsoup.parse(html.css("div.bmsg").nodes().get(1).toString()).text()); //jsoup解析,有两种数据,所以用jsoup
jobInfo.setCompanyInfo(Jsoup.parse(html.css("div.tmsg").toString()).text()); //jsoup解析
jobInfo.setJobName(html.css("div.cn h1","text").toString());
jobInfo.setJobAddr(html.css("div.cn span.lname","text").toString());
jobInfo.setJobInfo(Jsoup.parse(html.css("div.job_msg").toString()).text());
jobInfo.setUrl(page.getUrl().toString());

//获取薪资
Integer[] salary = MathSalary.getSalary(html.css("div.cn strong", "text").toString());
jobInfo.setSalaryMin(salary[0]);
jobInfo.setSalaryMax(salary[1]);

//获取发布时间
String time = Jsoup.parse(html.css("div.t1 span").regex(".*发布").toString()).text();
jobInfo.setTime(time.substring(0,time.length()-2));

//把结果保存起来,保存到了resultItems中
page.putField("jobInfo",jobInfo);
}


private Site site = Site.me()
.setCharset("gbk")//设置编码
.setTimeOut(10 * 1000)//设置超时时间
.setRetrySleepTime(3000)//设置重试的间隔时间
.setRetryTimes(3);//设置重试的次数

@Override
public Site getSite() {
return site;
}

@Autowired
private SpringDataPipeline springDataPipeline;

//initialDelay当任务启动后,等等多久执行方法
//fixedDelay每个多久执行方法
@Scheduled(initialDelay = 1000, fixedDelay = 100 * 1000)
public void process() {
Spider.create(new JobProcessor())
.addUrl(url)
.setScheduler(new QueueScheduler().setDuplicateRemover(new BloomFilterDuplicateRemover(100000))) //布隆过滤器 10w条
.thread(10)
.run();
}
}

6.3 使用和定制Pipeline

在WebMagic中,Pileline是抽取结束后,进行处理的部分,它主要用于抽取结果的保存,也可以定制Pileline可以实现一些通用的功能。在这里我们会定制Pipeline实现数据导入到数据库中

1.Pipeline输出

ipeline的接口定义如下:

public interface Pipeline {

// ResultItems保存了抽取结果,它是一个Map结构,
// 在page.putField(key,value)中保存的数据,
//可以通过ResultItems.get(key)获取
public void process(ResultItems resultItems, Task task);
}

可以看到,Pipeline其实就是将PageProcessor抽取的结果,继续进行了处理的,其实在Pipeline中完成的功能,你基本上也可以直接在PageProcessor实现,那么为什么会有Pipeline?有几个原因:
1.为了模块分离
“页面抽取”和“后处理、持久化”是爬虫的两个阶段,将其分离开来,一个是代码结构比较清晰,另一个是以后也可能将其处理过程分开,分开在独立的线程以至于不同的机器执行。

2.Pipeline的功能比较固定,更容易做成通用组件
每个页面的抽取方式千变万化,但是后续处理方式则比较固定,例如保存到文件、保存到数据库这种操作,这些对所有页面都是通用的。

在WebMagic里,一个Spider可以有多个Pipeline,使用Spider.addPipeline()即可增加一个Pipeline。这些Pipeline都会得到处理,例如可以使用

spider.addPipeline(new ConsolePipeline()).addPipeline(new FilePipeline())

实现输出结果到控制台,并且保存到文件的目标。

2.已有的Pipeline

WebMagic中就已经提供了控制台输出、保存到文件、保存为JSON格式的文件几种通用的Pipeline。

说明 备注
ConsolePipeline 输出结果到控制台 抽取结果需要实现toString方法
FilePipeline 保存结果到文件 抽取结果需要实现toString方法
JsonFilePipeline JSON格式保存结果到文件
ConsolePageModelPipeline (注解模式)输出结果到控制台
FilePageModelPipeline (注解模式)保存结果到文件
JsonFilePageModelPipeline (注解模式)JSON格式保存结果到文件 想持久化的字段需要有getter方法

3.自定义Pipeline导入数据

task包下创建:

@Component
public class SpringDataPipeline implements Pipeline {

@Autowired
private JobInfoService jobInfoService;


@Override
public void process(ResultItems resultItems, Task task) {
//获取封装好的招聘详情对象
JobInfo jobInfo = resultItems.get("jobInfo");

//判断数据是否不为空
if (jobInfo != null) {
//如果不为空把数据保存到数据库中
this.jobInfoService.save(jobInfo);
}
}
}

修改JobProcessor:

//注入自定义springDataPipeline
@Autowired
private SpringDataPipeline springDataPipeline;

//initialDelay当任务启动后,等等多久执行方法
//fixedDelay每个多久执行方法
//@Scheduled(initialDelay = 1000, fixedDelay = 100 * 1000)
public void process() {
Spider.create(new JobProcessor())
.addUrl(url)
.setScheduler(new QueueScheduler().setDuplicateRemover(new BloomFilterDuplicateRemover(100000)))
.thread(10)
.addPipeline(this.springDataPipeline) //自定义输出方式,入库
.run();
}

7.定时任务

我们使用的是Spring内置的Spring Task,这是Spring3.0加入的定时任务功能。我们使用注解的方式定时启动爬虫进行数据爬取。
我们使用的是 @Scheduled注解,其属性如下:
1)cron:cron表达式,指定任务在特定时间执行;
2)fixedDelay:上一次任务执行完后多久再执行,参数类型为long,单位ms
3)fixedDelayString:与fixedDelay含义一样,只是参数类型变为String 4)fixedRate:按一定的频率执行任务,参数类型为long,单位ms 5)fixedRateString: 与fixedRate的含义一样,只是将参数类型变为String 6)initialDelay:延迟多久再第一次执行任务,参数类型为long,单位ms 7)initialDelayString:与initialDelay的含义一样,只是将参数类型变为String 8)zone`:时区,默认为当前时区,一般没有用到

我们这里的使用比较简单,固定的间隔时间来启动爬虫。例如可以实现项目启动后,每隔一小时启动一次爬虫。

但是有可能业务要求更高,并不是定时定期处理,而是在特定的时间进行处理,这个时候我们之前的使用方式就不能满足需求了。例如我要在工作日(周一到周五)的晚上八点执行。这时我们就需要Cron表达式了。

7.1 Cron表达式

cron的表达式是字符串,实际上是由七子表达式,描述个别细节的时间表。这些子表达式是分开的空白,代表:

  1. Seconds
  2. Minutes
  3. Hours
  4. Day-of-Month
  5. Month
  6. Day-of-Week
  7. Year (可选字段)
    例: 0 0 12 ? * WED:在每星期三下午12:00 执行
    *:代表整个时间段

每一个字段都有一套可以指定有效值,如
Seconds (秒):可以用数字0-59 表示
Minutes(分) :可以用数字0-59 表示
Hours(时):可以用数字0-23表示
Day-of-Month(天):可以用数字1-31 中的任一一个值,但要注意一些特别的月份
Month(月) :可以用0-11 或用字符串:JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC
Day-of-Week(天):可以用数字1-7表示(1 = 星期日)或用字符口串:SUN, MON, TUE, WED, THU, FRI, SAT

“/”:为特别单位,则表示为“每”如“0/15”表示每隔15分钟(或秒)执行一次,“0”表示为从“0”分(或秒)开始, “3/20”表示表示每隔20分钟(或秒)执行一次“3”表示从第3分钟(或秒)开始执行
“?”:表示每月的某一天,或第周的某一天
“L”:用于每月,或每周,表示为每月的最后一天,或每个月的最后星期几如“6L”表示“每月的最后一个星期五”

例如:

@Component
public class TaskTest {

//从0秒开始,每隔5秒执行一次、每分钟、每小时、每天、每月、每周都执行。第七个年可以省略
@Scheduled(cron = "0/5 * * * * *")
public void test() {
System.out.println(LocalDateTime.now()+"任务执行了");
}
}