java就能写爬虫还要python干嘛?

5,985 阅读7分钟

爬虫学得好,牢饭吃得饱!!!切记!!!

相信大家多少都会接触过爬虫相关的需求吧,爬虫在绝大多数场景下,能够帮助客户自动的完成部分工作,极大的减少人工操作。目前更多的实现方案可能都是以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分为核心包和扩展包两个部分,所以我们需要引入如下两个依赖:


<properties>
    <webmagic.version>0.7.5</webmagic.version>
</properties>

<!--WebMagic-->
<dependency>
    <groupId>us.codecraft</groupId>
    <artifactId>webmagic-core</artifactId>
    <version>${webmagic.version}</version>
</dependency>
<dependency>
    <groupId>us.codecraft</groupId>
    <artifactId>webmagic-extension</artifactId>
    <version>${webmagic.version}</version>
</dependency>

到此为止,我们就成功的将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环境属于哪种系统,是ubuntucentos还是其他的种类,相应的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中自带的浏览器是chromiumchromium-chromedriver,且版本相应较低,但是足够我们的需求所使用了。

/ # apk search chromium
chromium-68.0.3440.75-r0
chromium-chromedriver-68.0.3440.75-r0

1.2.3 整合springboot

我们只需要在爬虫模块引入依赖就好了:

<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
</dependency>

二、三个案例

下面通过三个简单的案例,给大家实际展示使用效果。

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<Map<String, Object>> list = new ArrayList();
        // 页面所有元素集合
        List<String> all = new ArrayList<>();
        // 页面中子页面的链接地址
        List<String> 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<String> 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<RegionCodeDTO> regionCodeDTOS;
    }
    
    • 接下来对页面获取的内容(code、name、type)进行组装和临时存储,添加到children中:
    // 初始化子集
    List<RegionCodeDTO> 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<String> nextPage = new ArrayList<>();
    // 遍历上面的结果子集内容
    regionCodeDTO.getRegionCodeDTOS().forEach(regionCodeDTO1 -> {
        // 组装下一页集合
        nextPage.add(regionCodeDTO1.getUrl());
        // 定义并组装结果数据
        Map<String, Object> 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级别原理相同。

    image.png

  • 创建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<Map<String, String>> list = (List<Map<String, String>>) 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领域的大多数爬取场景。从页面数据、到静态网站图片,在到动态网站的图片截取。本文以提供思路为主,原理请参考相应的官方文档。

爬虫学得好,牢饭吃得饱!!!切记!!!