SeimiCrawler crawle la vidéo de la station B (les crawlers Java ne seront jamais esclaves)

SeimiCrawler est un framework de crawler Java agile, déployé indépendamment et distribué, dans l'espoir de minimiser le seuil permettant aux débutants de développer un système de crawler avec une haute disponibilité et de bonnes performances, et d'améliorer l'efficacité de développement du système de crawler. Dans le monde de SeimiCrawler, la plupart des gens ne se soucient que d'écrire la logique métier de l'exploration, et Seimi s'occupe du reste pour vous.

Les étudiants qui ont été en contact avec Jsoup doivent connaître JsoupXpath. JsoupXpath simplifie grandement l'utilisation de Jsoup crawler. Son auteur est l'auteur de SeimiCrawler, Wang Haomiao.

Salut au grand homme ici!

Le SeimiCrawler d'aujourd'hui peut être intégré à SpringBoot, et cet article utilisera SpringBoot pour une construction rapide. Il existe également des instructions détaillées dans les documents officiels , et cet article ne les répétera pas.

1. Construire un projet avec SpringBoot

Ici, j'utilise l'idée, et Spring Web est habituellement vérifié lors de la construction du projet. Nous avons juste besoin d'ajouter SeimiCrawlerdes dépendances officielles. Ici, j'ai ajouté une autre lombokdépendance. Si vous ne l'avez pas utilisée, vous pouvez l'ignorer et ajouter un constructeur à la classe d'entité.

        <dependency>
            <groupId>cn.wanghaomiao</groupId>
            <artifactId>SeimiCrawler</artifactId>
            <version>2.1.2</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.16.12</version>
        </dependency>
复制代码

2. Écrivez le code

1. La structure du projet est la suivante :

image.png

2. Classe d'entité :

package com.junki.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 视频实体类,暂时数据不保存到数据库,只是为了解析json,所以只写了两个字段。
 * @author junki
 * @date 2020/3/20 20:57
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Video {

    private String title;

    private String arcurl;

}
复制代码

3. Reptiles :

package com.junki.crawlers;

import cn.wanghaomiao.seimi.annotation.Crawler;
import cn.wanghaomiao.seimi.def.BaseSeimiCrawler;
import cn.wanghaomiao.seimi.struct.Request;
import cn.wanghaomiao.seimi.struct.Response;
import com.alibaba.fastjson.JSON;
import com.junki.entity.Video;
import jodd.io.FileUtil;
import org.jsoup.Connection;
import org.jsoup.Jsoup;
import org.seimicrawler.xpath.JXDocument;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * B站视频爬虫
 * 特别声明:本代码仅供学习使用,所爬取视频不得用于商业用途,请务必在24小时内删除!
 * @author junki
 */
@Crawler(name = "bili")
public class BiliCrawler extends BaseSeimiCrawler {

    /**
     * 创建基础目录,用于保存爬取的资源
     */
    private String baseDir = "E:/bilibili/";

    /**
     * 创建基础url,把分页处先替换成{pageNum}
     */
    private String baseUrl = "https://s.search.bilibili.com/cate/search?callback=jqueryCallback_bili_7223822642229205&main_ver=v3&search_type=video&view_type=hot_rank&order=click&copy_right=-1&cate_id=28&page={pageNum}&pagesize=20&jsonp=jsonp&time_from=20200313&time_to=20200320&_=1584708487005";

    /**
     * 用于保存cookie
     */
    private Map<String, String> cookies = null;

    /**
     * 获取需要爬取的网页入口地址
     * @return url数组
     */
    @Override
    public String[] startUrls() {
        // 创建数组存放前五页的url地址
        String[] urls = new String[5];
        for (int i = 0; i < 5; i++) {
            urls[i] = baseUrl.replace("{pageNum}", i + 1 + "");
        }

        // 获取cookie
        Connection.Response response = null;
        try {
            response = Jsoup.connect("https://www.bilibili.com/").ignoreContentType(true).execute();
        } catch (IOException e) {
            e.printStackTrace();
        }
        if (response != null) {
            cookies = response.cookies();
            logger.info("cookies={}", cookies);
        }

        return urls;
    }

    /**
     * 开始爬虫
     * @param response 自动请求url数组中url得到的响应体
     */
    @Override
    public void start(Response response) {
        // 获取返回的内容
        String content = response.getContent();

        // 获取其中的json部分
        String result = content.substring(content.indexOf("\"result\":") + 9, content.indexOf(",\"show_column\""));
        logger.info("{}", result);

        // 将json转为实体集合
        List<Video> videos = JSON.parseArray(result, Video.class);

        // 遍历集合获取每个视频的链接并进入视频页面
        videos.forEach(video -> {
            Request request = Request.build(video.getArcurl(), "getVideoAndAudioUrl");
            Map<String,String> header = new HashMap<>();
            header.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36");
            // 添加cookie,防止请求失败
            header.put("Cookie", cookies.toString().replace("{", "").replace("}", "").replace(",", ";"));
            // 将稿件标题添加到请求头,方便后续请求中获取,这里将特殊字符替换为空格,避免创建目录失败
            header.put("title", video.getTitle().replaceAll("[\\^/:*?\"<>|]", " ").trim());
            request.setHeader(header);
            push(request);
        });

    }

    /**
     * 爬取视频和音频的url地址
     * @param response 请求每个视频地址得到的响应体
     */
    public void getVideoAndAudioUrl(Response response) {
        logger.info("稿件地址:{}", response.getUrl());
        JXDocument doc = response.document();

        // 获取页面标题
        Object pageTitle = doc.selOne("//title/text()");

        // 标题为空或者包含出错啦,说明没有请求成功,直接结束
        if (pageTitle == null || pageTitle.toString().contains("出错啦")) {
            return;
        }

        // 获取页面源码内容
        String content = response.getContent();

        // B站较新的投稿,视频音频源都是分离的,所以分别获取视频和音频的地址
        int videoUrlBeginIndex = content.indexOf("\"baseUrl\":", content.indexOf("\"video\":"));
        int videoUrlEndIndex = content.indexOf("\",", videoUrlBeginIndex);
        String videoUrl = content.substring(videoUrlBeginIndex + 11, videoUrlEndIndex);
        int audioUrlBeginIndex = content.indexOf("\"baseUrl\":", content.indexOf("\"audio\":"));
        int audioUrlEndIndex = content.indexOf("\",", audioUrlBeginIndex);
        String audioUrl = content.substring(audioUrlBeginIndex + 11, audioUrlEndIndex);
        logger.info("视频地址:{}", videoUrl);
        logger.info("音频地址:{}", audioUrl);

        // 构造请求头参数
        Map<String,String> header = new HashMap<>();
        // 设置Referer,防止请求数据源失败
        header.put("Referer", response.getUrl());
        header.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36");
        header.put("title", response.getRequest().getHeader().get("title"));

        // 开始下载音频
        Request audioRequest = Request.build(audioUrl, "getAudio");
        audioRequest.setHeader(header);
        push(audioRequest);

        // 开始下载视频
        Request videoRequest = Request.build(videoUrl, "getVideo");
        videoRequest.setHeader(header);
        push(videoRequest);

    }

    /**
     * 音频下载
     * @param response 请求音频源的响应体
     */
    public void getAudio(Response response) {
        // 获取稿件标题
        String title = response.getRequest().getHeader().get("title");

        // 创建目录
        File file = new File(baseDir + title);
        file.mkdirs();

        // 开始爬取音频
        try {
            FileUtil.writeBytes(new File(baseDir + title + "/audio.mp3"), response.getData());
            logger.info("音频下载完成={}", title);
        } catch (IOException e) {
            logger.info("音频下载失败={};异常={}", title, e);
        }
    }

    /**
     * 视频下载
     * @param response 请求视频源的响应体
     */
    public void getVideo(Response response) {
        // 获取稿件标题
        String title = response.getRequest().getHeader().get("title");

        // 创建目录
        File file = new File(baseDir + title);
        file.mkdirs();

        // 开始爬取视频
        try {
            FileUtil.writeBytes(new File(baseDir + title + "/video.mp4"), response.getData());
            logger.info("视频下载完成={}", title);
        } catch (IOException e) {
            logger.info("视频下载失败={};异常={}", title, e);
        }

        // 判断音频是否爬取完毕,如果没有1秒后继续判断,音频比视频爬取快很多,一般不会判断很多次
        while (! new File(baseDir + title + "/audio.mp3").exists()) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 如果音频文件也爬取完毕,就开始合并视频音频
        mergeVideoAndAudio(this.getClass().getClassLoader().getResource("ffmpeg.exe").getPath(), baseDir + title + "/video.mp4", baseDir + title + "/audio.mp3", baseDir + title + "/merge.mp4");
        logger.info("视频音频合并成功={}", title);
    }

    /**
     * 合并视频音频
     * @param ffmpegPath ffmpeg.exe路径
     * @param videoPath 视频文件路径
     * @param audioPath 音频文件路径
     * @param outPath 合并后输出路径
     */
    private static void mergeVideoAndAudio(String ffmpegPath, String videoPath, String audioPath, String outPath) {
        List<String> commend = new ArrayList<>();
        commend.add(ffmpegPath);
        commend.add("-i");
        commend.add(audioPath);
        commend.add("-i");
        commend.add(videoPath);
        commend.add("-acodec");
        commend.add("copy");
        commend.add("-vcodec");
        commend.add("copy");
        commend.add(outPath);
        try {
            ProcessBuilder builder = new ProcessBuilder(commend);
            builder.command(commend);
            Process p = builder.start();
            if (p.isAlive()) {
                p.waitFor();
            }
            p.destroy();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}
复制代码

4. application.propertiesFichier de configuration :

#启动SeimiCrawler
seimi.crawler.enabled=true
seimi.crawler.names=bili
复制代码

Fichier 5.ffmpeg.exe :

Vous pouvez vous rendre sur le site officiel de ffmpeg pour télécharger, vous pouvez sélectionner Windows ci-dessous et télécharger le fichier exe.

Troisièmement, l'analyse du code de base

1. Annotation @Crawler

image.pngPour cette annotation, j'ai seulement écrit un attribut name, qui correspond au nom dans le fichier de configuration, indiquant que cette classe de crawler est démarrée.

2. Paramètres d'attribut

image.png

Les trois propriétés ici sont utilisées pour définir le chemin de stockage des ressources d'exploration, le chemin de base de la page de démarrage et le cookie global. Vous pouvez également envisager de configurer et d'injecter des valeurs dans le fichier de configuration.

这里着重讲解起始页面基路径:获取起始路径是学习爬虫的关键之一,这里我略讲路径获取过程: 首先我看上了B站音乐区: image.png 然后准备爬取热榜: image.png 此处视频时延迟加载: image.png 分析network找到请求路径,点击翻页瞬间就找到了: image.png 将请求拿到浏览器直接访问,我们要的数据就在result里面了: image.png 至此,地址我们已经拿到了,找到分页参数,我将它替换成了{pageNum}方便后续进行字符串替换。

3.核心方法

这里我们继承BaseSeimiCrawler之后重写了两个方法:

image.png

其中startUrls用户返回所要爬取的地址,我这里通过遍历生成了前5页的地址,你可可以自行修改,或者通过配置文件配置。 需要注意的是,这里我用了原生Jsoup获取了Cookie: image.png

其中start方法用于开始爬虫,这里通过字符串切割获取需要的json部分然后解析成实体集合: image.png 需要注意的是,这里创建请求对象时,指定的callBack就是我下方编写的处理方法名,Seimi会自动帮我发送请求,并用指定方法获取响应,此过程有消息队列,多线程异步: image.png

4.其它方法

getVideoAndAudioUrl方法获取视频和音频地址是关键,B站较新的视频都是视频音频分离的,网页源码中可以找到地址,比较老的视频只有一个视频,获取方式不一样。另外不同分区略有差别,特别是电影区,这部分的优化我会在后续的博客中更新。 image.png

既然音频视频分离,那就一定要合并音频视频,所以有了mergeVideoAndAudio方法,这里调用了FFmpeg实现了合并,效率极高。FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。这里不再对FFmpeg进行赘述。

四、运行结果

直接运行SpringBoot启动类即可,为了让大家看到效果,我加了大量日志,你可以选择关闭日志。

image.png

fichiers locaux:image.png

Il y a trois fichiers dans chaque répertoire, qui sont de l'audio pur, de la vidéo pure et de la vidéo fusionnée :image.png

5. Déclaration spéciale

Cet article vise à utiliser des exemples pour expliquer le robot d'exploration SeimiCrawler, pas pour la station B.

Ce code est destiné à l'apprentissage uniquement et les vidéos explorées ne doivent pas être utilisées à des fins commerciales, veuillez vous assurer de les supprimer dans les 24 heures !

Je suppose que tu aimes

Origine juejin.im/post/7086299433427533861
conseillé
Classement