1. 需求分析
-
首先访问京东,搜索手机,分析页面,我们抓取以下商品数据:商品图片、价格、标题、商品详情页,
-
SPU和SKU
除了这四个属性以外,我们发现中的苹果手机有四种产品,我们应该每一种都要抓取。那么这里就必须要了解spu和sku的概念:SPU = Standard Product Unit (标准产品单位)
SPU是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。通俗点讲,属性值、特性相同的商品就可以称为一个SPU。
例如上图中的苹果手机就是SPU,包括红色、深灰色、金色、银色 SKU=stock keeping unit(库存量单位)
SKU即库存进出计量的单位,可以是以件、盒、托盘等为单位。SKU是物理上不可分割的最小存货单元。在使用时要根据不同业态,不同管理模式来处理。在服装、鞋类商品中使用最多最普遍。
例如上图中的苹果手机有几个款式,红色苹果手机,就是一个sku.查看页面的源码也可以看出区别
2. 开发准备
-
创建的数据库表,IDEA集成Mysql,可以看这篇文章:
DROP database IF EXISTS `crawler`; create database crawler; use crawler; DROP database IF EXISTS `jd_item`; 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='京东商品表';
-
添加依赖
使用Spring Boot+Spring Data JPA和定时任务进行开发,需要创建Maven工程并添加以下依赖<?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> <groupId>cn.itcast</groupId> <artifactId>itcast-crawler-jd</artifactId> <version>1.0-SNAPSHOT</version> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.0.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> <version>5.1.6</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>
-
添加配置文件,加入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
3. 代码实现
- 3.1 编写pojo
根据数据库表,编写pojopackage cn.itcast.jd.pojo; import javax.persistence.*; import java.util.Date; @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
package cn.itcast.jd.dao; import cn.itcast.jd.pojo.Item; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface ItemDao extends JpaRepository<Item,Long> { }
- 3.3. 编写Service
ItemService接口
ItemServiceImpl实现类package cn.itcast.jd.service; import cn.itcast.jd.pojo.Item; import org.springframework.stereotype.Service; import java.util.List; public interface ItemService { /** * 保存商品 * @param item */ public void save(Item item); /** * 根据条件查询商品 * @param item * @return */ public List<Item> findAll(Item item); }
package cn.itcast.jd.service.impl; import cn.itcast.jd.dao.ItemDao; import cn.itcast.jd.pojo.Item; import cn.itcast.jd.service.ItemService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Example; import org.springframework.stereotype.Service; import javax.transaction.Transactional; import java.util.List; @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; } }
- 3.4. 编写引导类
package cn.itcast.jd; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication //设置开启定时任务 @EnableScheduling public class Application { public static void main(String[] args) { SpringApplication.run(Application.class,args); } }
- 3.5. 封装HttpClient
需要经常使用HttpClient,所以需要进行封装,方便使用package cn.itcast.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.util.UUID; @Component public class HttpUtils { private PoolingHttpClientConnectionManager cm; public HttpUtils() { this.cm = new PoolingHttpClientConnectionManager(); //设置最大连接数 cm.setMaxTotal(200); //设置每个主机的并发数 cm.setDefaultMaxPerRoute(20); } /** * 根据请求地址下载页面数据 * * @param url * @return 页面数据 */ public String doGetHtml(String url) { //获取HttpClient对象 CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(this.cm).build(); //声明httpGet请求对象 HttpGet httpGet = new HttpGet(url); //设置请求参数RequestConfig httpGet.setConfig(this.getConfig()); //设置请求Request Headers中的User-Agent,告诉京东说这是浏览器访问 httpGet.addHeader("User-Agent","Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Mobile Safari/537.36"); CloseableHttpResponse response=null; try { //使用HttpClient发起请求,返回response response = httpClient.execute(httpGet); //解析response返回数据 if (response.getStatusLine().getStatusCode() == 200) { String html = ""; //如果response.getEntity获取的结果为空,在执行EntityUtils.toString会报错 //需要对Entity进行非空的判断 if (response.getEntity() != null) { html = EntityUtils.toString(response.getEntity(), "utf8"); } return html; } } catch (Exception e) { e.printStackTrace(); } finally { try { if (response != null) { response.close(); } // 不能关闭,现在使用的是连接管理器 // httpClient.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请求对象 HttpGet httpGet = new HttpGet(url); //设置请求参数RequestConfig httpGet.setConfig(this.getConfig()); CloseableHttpResponse response = null; try { //使用HttpClient发起请求,返回response response = httpClient.execute(httpGet); //解析response,返回结果 if (response.getStatusLine().getStatusCode() == 200) { //图片后缀 String extName = url.substring(url.lastIndexOf(".")); //重命名 String imageName = UUID.randomUUID().toString() + extName; //声明输出的文件 FileOutputStream outputStream = new FileOutputStream(new File("D:\\images\\" + imageName)); //下载图片 response.getEntity().writeTo(outputStream); //返回生成的图片名 return imageName; } } catch (Exception e) { e.printStackTrace(); } finally { try { if (response != null) { response.close(); } // 不能关闭,现在使用的是连接管理器 // httpClient.close(); } catch (IOException e) { e.printStackTrace(); } } //下载失败,返回空串 return ""; } //获取请求参数对象 private RequestConfig getConfig() { RequestConfig config = RequestConfig.custom() .setConnectTimeout(1000) //创建连接的最长时间 .setConnectionRequestTimeout(500) //获取连接的最长时间 .setSocketTimeout(100000) //数据传输的最长时间 .build(); return config; } }
- 3.6. 实现数据抓取
使用定时任务,可以定时抓取最新的数据
爬取成功package cn.itcast.jd.task; import cn.itcast.jd.pojo.Item; import cn.itcast.jd.service.ItemService; import cn.itcast.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.util.Date; import java.util.List; @Component public class ItemTask { @Autowired private HttpUtils httpUtils; @Autowired private ItemService itemService; 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&wq=%E6%89%8B%E6%9C%BA&s=120&click=0&page="; //按照页面对手机的搜索结果进行遍历解析 for (int i = 1; i < 5; i = i + 2) { String html = httpUtils.doGetHtml(url + i); //解析页面,获取商品数据并存储 if (html !=null){ this.parse(html); } } System.out.println("手机数据抓取完成!"); } /** * 解析页面,获取商品数据并存储 * @param html * @throws Exception */ private void parse(String html) throws Exception { //解析html获取Document Document doc = Jsoup.parse(html); //获取spu信息 Elements spuEles = doc.select("div#J_goodsList > ul > li"); for (Element spuEle : spuEles) { //获取spu String attr = spuEle.attr("data-spu"); long spu = Long.parseLong(attr.equals("")?"0":attr); //获取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"); //图片路径可能会为空的情况:一下为两种解决方式,第一种会让数据不全,第二种任会报错 if(picUrl.equals("https:")){ break; } /*if(!StringUtils.isNotBlank(picUrl)){ picUrl ="https:"+ skuEle.select("img[data-sku]").first().attr("data-lazy-img-slave"); }*/ 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. bug分析
-
爬取页面失败,跳到登录页面的地址,
<script>window.location.href='https://passport.jd.com/uc/login'</script>
解决:设置cookie,进入京东页面,开发者选项,找到请求头如下图,复制到HttpUtils工具类即可,直接复制以下代码也可以:
//设置请求Request Headers中的User-Agent,告诉京东说这是浏览器访问 httpGet.addHeader("User-Agent","Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Mobile Safari/537.36");
-
报告连接异常,或许是网速太慢了,超出了最长连接时间,将时间延长即可:
//获取请求参数对象 private RequestConfig getConfig() { RequestConfig config = RequestConfig.custom() .setConnectTimeout(1000) //创建连接的最长时间 .setConnectionRequestTimeout(500) //获取连接的最长时间 .setSocketTimeout(100000) //数据传输的最长时间 .build(); return config; }
-
报错:
java.lang.IllegalArgumentException
因为long spu = Long.parseLong(spuEle.attr(“data-spu”));
直接写这个语句也会报类型转换异常,字符串为空是不知道怎么转换,所以可修改为//获取spu String attr = spuEle.attr("data-spu"); long spu = Long.parseLong(attr.equals("")?"0":attr);
-
报错:
java.lang.IllegalArgumentException
是因为图片路径可能会为空的情况//图片路径可能会为空的情况:以下为两种解决方式,第一种会让数据不全,第二种任会报错 if(picUrl.equals("https:")){ break; } if(!StringUtils.isNotBlank(picUrl)){ picUrl ="https:"+ skuEle.select("img[data-sku]").first().attr("data-lazy-img-slave"); }
参考: https://blog.csdn.net/hellowork10/article/details/106292150