爬虫学得好,牢饭吃得饱!!!切记!!!
相信大家多少都会接触过爬虫相关的需求吧,爬虫在绝大多数场景下,能够帮助客户自动的完成部分工作,极大的减少人工操作。目前更多的实现方案可能都是以python为实现基础,但是作为java程序员,咱们需要知道的是,以java 的方式,仍然可以很方便、快捷的实现爬虫。下面将会给大家介绍两种以java为基础的爬虫方案,同时提供案例供大家参考。
一、两种方案
传统的java实现爬虫方案,都是通过jsoup的方式,本文将采用一款封装好的框架【webmagic】进行实现。同时针对一些特殊的爬虫需求,将会采用【selenium-java】的进行实现,下面针对两种实现方案进行简单介绍和演示配置方式。
1.1 webmagic
官方文档:webmagic.io/
1.1.1 简介
使用webmagic开发爬虫,能够非常快速的实现简单且逻辑清晰的爬虫程序。
四大组件
- Downloader:下载页面
- PageProcessor:解析页面
- Scheduler:负责管理待抓取的URL,以及一些去重的工作。通常不需要自己定制。
- Pipeline:获取页面解析结果,数持久化。
Spider
- 启动爬虫,整合四大组件
1.1.2 整合springboot
webmagic分为核心包和扩展包两个部分,所以我们需要引入如下两个依赖:
0.7.5
us.codecraft
webmagic-core
${webmagic.version}
us.codecraft
webmagic-extension
${webmagic.version}
到此为止,我们就成功的将webmagic引入进来了,具体使用,将在后面的案例中详细介绍。
1.2 selenium-java
官网地址:www.selenium.dev/
1.2.1 简介
selenium是一款浏览器自动化工具,它能够模拟用户操作浏览器的交互。但前提是,我们需要在使用他的机器(windows/linux等)安装上它需要的配置。相比于webmigc的安装,它要繁琐的多了,但使用它的原因,就是为了解决一些webmagic做不到的事情。
支持多种语言:java、python、ruby、javascript等。其使用代码非常简单,以java为例如下:
package dev.selenium.hello;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
public class HelloSelenium {
public static void main(String[] args) {
WebDriver driver = new ChromeDriver();
driver.get("https://selenium.dev");
driver.quit();
}
}
1.2.2 安装
无论是在windows还是linux上使用selenium,都需要两个必要的组件:
- 浏览器(chrome)
- 浏览器驱动 (chromeDriver)
需要注意的是,要确保上述两者的版本保持一致。
下载地址
chromeDriver:chromedriver.storage.googleapis.com/index.html
windows
windows的安装相对简单一些,将chromeDriver.exe下载至电脑,chrome浏览器直接官网下载相应安装包即可。严格保证两者版本一致,否则会报错。
在后面的演示程序当中,只需要通过代码指定chromeDriver的路径即可。
linux
linux安装才是我们真正的使用场景,java程序通常是要部署在linux环境的。所以我们需要linux的环境下安装chrome和chromeDriver才能实现想要的功能。
首先要做的是判断我们的linux环境属于哪种系统,是ubuntu
、centos
还是其他的种类,相应的shell脚本都是不同的。
我们采用云原生的环境,所有的服务均以容器的方式部署,所以要在每一个服务端容器内部安装chrome和chromeDiver。我们使用的是Alpine Linux
,一个轻量级linux发行版,非常适合用来做Docker镜像。
我们可以通过apk --help
去查看相应的命令,我直接给出安装命令:
# Install Chrome for Selenium
RUN apk add gconf
RUN apk add chromium
RUN apk add chromium-chromedriver
上面的内容,可以放在DockerFile文件中,在部署的时候,会直接将相应组件安装在容器当中。
需要注意的是,在Alpine Linux中自带的浏览器是chromium
和chromium-chromedriver
,且版本相应较低,但是足够我们的需求所使用了。
/ # apk search chromium
chromium-68.0.3440.75-r0
chromium-chromedriver-68.0.3440.75-r0
1.2.3 整合springboot
我们只需要在爬虫模块引入依赖就好了:
org.seleniumhq.selenium
selenium-java
二、三个案例
下面通过三个简单的案例,给大家实际展示使用效果。
2.1 爬取省份街道
使用webmagic进行省份到街道的数据爬取。注意,本文只提供思路,不提供具体爬取网站信息,请同学们自己根据使用选择。
接下来搭建webmagic的架子,其中有几个关键点:
- 创建页面解析类,实现PageProcessor。
import us.codecraft.webmagic.Page;
import us.codecraft.webmagic.Site;
import us.codecraft.webmagic.processor.PageProcessor;
/**
* 页面解析
*
* @author wjbgn
* @date 2023/8/15 17:25
**/
public class TestPageProcessor implements PageProcessor {
@Override
public void process(Page page) {
}
@Override
public Site getSite() {
return site;
}
/**
* 初始化Site配置
*/
private Site site = Site.me()
// 重试次数
.setRetryTimes(3)
//编码
.setCharset(StandardCharsets.UTF_8.name())
// 超时时间
.setTimeOut(10000)
// 休眠时间
.setSleepTime(1000);
}
-
实现PageProcessor后,要重写其方法process(Page page),此方法是我们实现爬取的核心(页面解析)。通常省市区代码分为6级,所以常见的网站均是按照层级区分,我们是从省份开始爬取,即从第三层开始爬取。
- 初始化变量
@Override public void process(Page page) { // 市级别 Integer type = 3; // 初始化结果明细 RegionCodeDTO regionCodeDTO = new RegionCodeDTO(); // 带有父子关系的结果集合 List list = new ArrayList(); // 页面所有元素集合 List all = new ArrayList(); // 页面中子页面的链接地址 List urlList = new ArrayList(); }
- 根据不同级别,获取相应页面不同的元素
if (CollectionUtil.isEmpty(all)) { // 爬取所有的市,编号,名称 all = page.getHtml().css("table.citytable").css("tr").css("a", "text").all(); // 爬取所有的城市下级地址 urlList = page.getHtml().css("table.citytable").css("tr").css("a", "href").all() .stream().distinct().collect(Collectors.toList()); if (CollectionUtil.isEmpty(all)) { // 区县级别 type = 4; all = page.getHtml().css("table.countytable").css("tr.countytr").css("td", "text").all(); // 获取区 all.addAll(page.getHtml().css("table.countytable").css("tr.countytr").css("a", "text").all()); urlList = page.getHtml().css("table.countytable").css("tr").css("a", "href").all() .stream().distinct().collect(Collectors.toList()); if (CollectionUtil.isEmpty(all)) { // 街道级别 type = 5; all = page.getHtml().css("table.towntable").css("tr").css("a", "text").all(); urlList = page.getHtml().css("table.towntable").css("tr").css("a", "href").all() .stream().distinct().collect(Collectors.toList()); if (CollectionUtil.isEmpty(all)) { // 村,委员会 type = 6; List village = new ArrayList(); all = page.getHtml().css("table").css("tr.villagetr").css("td", "text").all(); for (int i = 0; i < all.size(); i++) { if (i % 3 != 1) { village.add(all.get(i)); } } all = village; } } } }
- 定义一个实体类RegionCodeDTO,用来存放临时获取的code,url以及父子关系等内容:
public class RegionCodeDTO { private String code; private String parentCode; private String name; private Integer type; private String url; private List regionCodeDTOS; }
- 接下来对页面获取的内容(code、name、type)进行组装和临时存储,添加到children中:
// 初始化子集 List children = new ArrayList(); // 初始化临时节点数据 RegionCodeDTO region = new RegionCodeDTO(); // 解析页面结果集all当中的数据,组装到region 和 children当中 for (int i = 0; i < all.size(); i++) { if (i % 2 == 0) { region.setCode(all.get(i)); } else { region.setName(all.get(i)); } if (StringUtils.isNotEmpty(region.getCode()) && StringUtils.isNotEmpty(region.getName())) { region.setType(type); // 添加子集到集合当中 children.add(region); // 重新初始化 region = new RegionCodeDTO(); } }
- 组装页面链接,并将页面链接组装到children当中。
// 循环遍历页面元素获取的子页面链接 for (int i = 0; i < urlList.size(); i++) { String url = null; if (StringUtils.isEmpty(urlList.get(0))) { continue; } // 拼接链接,页面的子链接是相对路径,需要手动拼接 if (urlList.get(i).contains(provinceEnum.getCode() + "/")) { url = provinceEnum.getUrlPrefixNoCode(); } else { url = provinceEnum.getUrlPrefix(); } // 将链接放到临时数据子集对象中 if (urlList.get(i).substring(urlList.get(i).lastIndexOf("/") + 1, urlList.get(i).indexOf(".html")).length() == 9) { children.get(i).setUrl(url + page.getUrl().toString().substring(page.getUrl().toString().indexOf(provinceEnum.getCode() + "/") + 3 , page.getUrl().toString().lastIndexOf("/")) + "/" + urlList.get(i)); } else { children.get(i).setUrl(url + urlList.get(i)); } }
- 将children添加到结果对象当中
// 将子集放到集合当中 regionCodeDTO.setRegionCodeDTOS(children);
-
在下面的代码当中将进行两件事儿:
-
处理下一页,通过page的addTargetRequests方法,可以进行下一页的跳转,此方法参数可以是listString和String,即支持多个页面跳转和单个页面的跳转。
-
将数据传递到Pipeline,用于数据的存储,Pipeline的实现将在后面具体说明。
-
// 定义下一页集合 List nextPage = new ArrayList(); // 遍历上面的结果子集内容 regionCodeDTO.getRegionCodeDTOS().forEach(regionCodeDTO1 -> { // 组装下一页集合 nextPage.add(regionCodeDTO1.getUrl()); // 定义并组装结果数据 Map map = new HashMap(); map.put("regionCode", regionCodeDTO1.getCode()); map.put("regionName", regionCodeDTO1.getName()); map.put("regionType", regionCodeDTO1.getType()); map.put("regionFullName", regionCodeDTO1.getName()); map.put("regionLevel", regionCodeDTO1.getType()); list.add(map); // 推送数据到pipeline page.putField("list", list); }); // 添加下一页集合到page page.addTargetRequests(nextPage);
-
当本次process方法执行完后,将会根据传递过来的链接地址,再次执行process方法,根据前面定义的读取页面元素流程的代码,将不符合type=3的内容,所以将会进入到下一级4的爬取过程,5、6级别原理相同。
-
创建Pipeline,用于编写数据持久化过程。经过上面的逻辑,已经将所需内容全部获取到,接下来将通过pipline进行数据存储。首先定义pipeline,并实现其process方法,获取结果内容,具体存储数据的代码就不展示了,需要注意的是,此处pipeline没有通过spring容器托管,需要调用业务service需要使用SpringUtils进行获取:
public class RegionDataPipeline implements Pipeline{ @Override public void process(ResultItems resultItems, Task task) { // 获取service IXXXXXXXXXService service = SpringUtils.getBean(IXXXXXXXXXService.class); // 获取内容 List list = (List) resultItems.getAll().get("list"); // 解析数据,转换为对应实体类 // service.saveBatch }
-
启动爬虫
//启动爬虫 Spider.create(new RegionCodePageProcessor(provinceEnum)) .addUrl(provinceEnum.getUrl()) .addPipeline(new RegionDataPipeline()) //此处不能小于2 .thread(2).start()
2.2 爬取网站静态图片
爬取图片是最常见的需求,我们通常爬取的网站都是静态的网站,即爬取的内容都在网页上面渲染完成的,我们可以直接通过获取页面元素进行抓取。
可以参考下面的文章,直接拉取网站上的图片:juejin.cn/post/705138…
针对获取到的图片网络地址,直接使用如下方式进行下载即可:
url = new URL(imageUrl);
//打开连接
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
//设置请求方式为"GET"
conn.setRequestMethod("GET");
//超时响应时间为10秒
conn.setConnectTimeout(10 * 1000);
//通过输入流获取图片数据
InputStream is = conn.getInputStream();
2.3 爬取网站动态图片
在2.2中我们可以很快地爬取到对应的图片,但是在另外两种场景下,我们获取图片将会不适用上面的方式:
- 需要拼图,且多层的gis相关图片,此种图片将会在后期进行复杂的图片处理(按位置拼接瓦片,多层png图层叠加),才能获取到我们想要的效果。
- 动态js加载的图片,直接无法通过css、xpath获取。
所以在这种情况下我们可以使用开篇介绍的selenium-java来解决,本文使用的仅仅是截图的功能,来达到我们需要的效果。具体街区全屏代码如下所示:
public File getItems() {
// 获取当前操作系统
String os = System.getProperty("os.name");
String path;
if (os.toLowerCase().startsWith("win")) {
//windows系统
path = "driver/chromedriver.exe";
} else {
//linux系统
path = "/usr/bin/chromedriver";
}
WebDriver driver = null;
// 通过判断 title 内容等待搜索页面加载完毕,间隔秒
try {
System.setProperty("webdriver.chrome.driver", path);
ChromeOptions chromeOptions = new ChromeOptions();
chromeOptions.addArguments("--headless");
chromeOptions.addArguments("--no-sandbox");
chromeOptions.addArguments("--disable-gpu");
chromeOptions.addArguments("--window-size=940,820");
driver = new ChromeDriver(chromeOptions);
// 截图网站地址
driver.get(UsaRiverConstant.OBSERVATION_POINT_URL);
// 休眠用于网站加载
Thread.sleep(15000);
// 截取全屏
File screenshotAs = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
return screenshotAs;
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
driver.quit();
}
}
如上所示,我们获取的是整个页面的图片,还需要对截取的图片进行相应的剪裁,保留我们需要的区域,如下所示:
public static void cutImg(InputStream inputStream, int x, int y, int width, int height, OutputStream outputStream) {//图片路径,截取位置坐标,输出新突破路径
InputStream fis = inputStream;
try {
BufferedImage image = ImageIO.read(fis);
//切割图片
BufferedImage subImage = image.getSubimage(x, y, width, height);
Graphics2D graphics2D = subImage.createGraphics();
graphics2D.drawImage(subImage, 0, 0, null);
graphics2D.dispose();
//输出图片
ImageIO.write(subImage, "png", outputStream);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
fis.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
三、小结
通过如上两个组件的简单介绍,足够应付在java领域的大多数爬取场景。从页面数据、到静态网站图片,在到动态网站的图片截取。本文以提供思路为主,原理请参考相应的官方文档。
爬虫学得好,牢饭吃得饱!!!切记!!!