有悲欢离合,月有阴晴圆缺,此事古难全。

——苏轼《水调歌头》

# 京东手机数据爬取案例

学习了HttpClient 和l Jsoup,就掌握了如何抓取数据和如何解析数据,接下来,我们做一个小练习,把京东的手机数据抓取下来。

主要目的是 HttpClient和 Jsoup 的学习。

1. 需求分析

首先访问京东,搜索手机,分析页面,我们抓取以下商品数据:商品图片、价格、标题、商品详情页。

image-20201222161237923

1.1 SPU和 SKU

除了以上四个属性以外,我们发现上图中的苹果手机有四种产品,我们应该每一种都要抓取。那么这里就必须要了解 spu 利 sku 的概念。

SPU = Standard Product Unit(标准产品单位)

SPU是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。通俗点讲,属性值、特性相同的商品就可以称为一个SPU。

例如上图中的苹果手机就是SPU,包括红色、深灰色、金色、银色。

SKU=stock keeping unit(库存量单位)

SKU即库存进出计量的单位,可以是以件、盒、托盘等为单位。SKU是物理上不可分割的最小存货单元。在使用时要根据不同业态,不同管理模式来处理。在服装、鞋类商品中使用最多最普遍。

例如上图中的苹果手机有几个款式,红色苹果手机,就是一个SKU。

2. 开发准备

2.1 数据库表分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 创建crawler数据库, 再创建表
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.2 添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?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.itbuild</groupId>
<artifactId>itbuild-crawler-jd</artifactId>
<version>1.0-SNAPSHOT</version>

<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>
<version>8.0.11</version>
</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>
</project>

2.3 添加配置文件

application.properties

1
2
3
4
5
6
7
8
9
#DB Configuration
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/crawler?useSSL=false&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root

#JPA
spring.jpa.database=MYSQL
spring.jpa.show-sql=true

3. 代码实现

3.1 编写pojo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
package cn.itbuild.jd.pojo;

import javax.persistence.*;
import java.util.Date;

/**
* @Date 2020/12/22 16:46
* @Version 10.21
* @Author DuanChaojie
*/
@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;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public Long getSpu() {
return spu;
}

public void setSpu(Long spu) {
this.spu = spu;
}

public Long getSku() {
return sku;
}

public void setSku(Long sku) {
this.sku = sku;
}

public String getTitle() {
return title;
}

public void setTitle(String title) {
this.title = title;
}

public Double getPrice() {
return price;
}

public void setPrice(Double price) {
this.price = price;
}

public String getPic() {
return pic;
}

public void setPic(String pic) {
this.pic = pic;
}

public String getUrl() {
return url;
}

public void setUrl(String url) {
this.url = url;
}

public Date getCreated() {
return created;
}

public void setCreated(Date created) {
this.created = created;
}

public Date getUpdated() {
return updated;
}

public void setUpdated(Date updated) {
this.updated = updated;
}
}

3.2 编写dao

1
2
3
4
5
6
7
8
9
10
package cn.itbuild.jd.dao;

import cn.itbuild.jd.pojo.Item;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;


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

3.3 编写Service

ItemService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package cn.itbuild.jd.service;

import cn.itbuild.jd.pojo.Item;

import java.util.List;

public interface ItemService {

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

/**
* 根据条件查询商品
* @param item
* @return
*/
public List<Item> findAll(Item item);
}
ItemServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package cn.itbuild.jd.service.impl;

import cn.itbuild.jd.dao.ItemDao;
import cn.itbuild.jd.pojo.Item;
import cn.itbuild.jd.service.ItemService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
import org.springframework.stereotype.Service;

import java.util.List;

/**
* @Date 2020/12/22 16:50
* @Version 10.21
* @Author DuanChaojie
*/
@Service
public class ItemServiceImpl implements ItemService {

@Autowired
private ItemDao itemDao;

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

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

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

return list;
}
}

3.4 编写引导类

1
2
3
4
5
6
7
8
9
10
11
12
13
package cn.itbuild.jd;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling//使用定时任务, 需要先开启定时任务, 需要添加注解
public class JdApplication {
public static void main(String[] args) {
SpringApplication.run(JdApplication.class, args);
}
}

3.5 封装HttpClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
package cn.itbuild.jd.utils;

import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.util.EntityUtils;
import org.springframework.stereotype.Component;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.UUID;

@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(getConfig());

// 设置请求头, 伪装用户
setHeaders(httpGet);

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(getConfig());

// 设置请求头, 伪装用户
setHeaders(httpGet);

CloseableHttpResponse response = null;

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

// 解析响应, 返回结果
if (response.getStatusLine().getStatusCode() == 200) {
// 判断响应体Entity是否不为空, 如果不为空就可以使用EntityUtils
if (response.getEntity() != null) {
// 下载图片
// 获取图片的后缀
String extName = url.substring(url.lastIndexOf("."));
// 创建图片名, 重命名图片
String picName = UUID.randomUUID().toString() + extName;
// 下载图片
// 声明OutPutStream
OutputStream outputStream = new FileOutputStream(new File("E:/file/gitee/crawler/jd-image/" + 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;
}

// 设置请求头
private void setHeaders(HttpGet httpGet) {
// 使用HttpClient爬取数据时, 为了防止被网站拦截, 应该设置请求头
httpGet.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.75 Safari/537.36");
}
}

3.6 实现数据抓取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
package cn.itbuild.jd.task;

import cn.itbuild.jd.pojo.Item;
import cn.itbuild.jd.service.ItemService;
import cn.itbuild.jd.utils.HttpUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.Date;
import java.util.List;

/**
* @Date 2020/12/22 17:05
* @Version 10.21
* @Author DuanChaojie
*/
@Component
public class ItemTask {

@Autowired
private HttpUtils httpUtils;

@Autowired
private ItemService itemService;

private static final ObjectMapper MAPPER = new ObjectMapper();

/**
* 当下载任务完成后,间隔多长时间进行下一次的任务
* @throws Exception
*/
@Scheduled(fixedDelay = 10*1000)
public void itemTask() throws Exception{
// 生明需要解析的初始地址
String utl = "https://search.jd.com/search?keyword=%E6%89%8B%E6%9C%BA&wq=%E6%89%8B%E6%9C%BA&ev=559_103811%5E&s=57&click=0&page=";

// 按照页面对手机的搜索结果进行遍历解析
for (int i = 19; i < 20; i = i + 2) {
String html = httpUtils.doGetHtml(utl + i);
this.parse(html);
}

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

/**
* 解析html页面,获取商品数据并存储,核心逻辑
* @param html
*/
private void parse(String html) throws IOException {
// 解析html获取Document对象
Document doc = Jsoup.parse(html);

// 获取spuEles信息
Elements spuEles = doc.select("div#J_goodsList>ul>li");

// 遍历spuEles
for (Element spuEle : spuEles) {
// 排除没有data-spu的值的内容
if (StringUtils.isNotEmpty(spuEle.attr("data-spu"))) {
// 获取spu
long spu = Long.parseLong(spuEle.attr("data-spu"));

// 获取sku信息
Elements skuEles = spuEle.select("ul.ps-main>li.ps-item");
// 根据sku过去商品数据
for (Element skuEle : skuEles) {
// 获取sku
long sku = Long.parseLong(skuEle.select("[data-sku]").first().attr("data-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("/n7/","/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);
}
}
}

}
}