SeimiCrawler是一个敏捷的,独立部署的,支持分布式的Java爬虫框架,希望能在最大程度上降低新手开发一个可用性高且性能不差的爬虫系统的门槛,以及提升开发爬虫系统的开发效率。在SeimiCrawler的世界里,绝大多数人只需关心去写抓取的业务逻辑就够了,其余的Seimi帮你搞定。
接触过Jsoup的同学肯定了解过JsoupXpath,JsoupXpath极大简化了Jsoup爬虫的使用,他的作者就是SeimiCrawler的作者,汪浩淼。
在此向大佬致敬!
如今的SeimiCrawler已经可以整合入SpringBoot,本文将使用SpringBoot进行快速构建。
官方文档
中也有详细说明,本文不再赘述。
一、使用SpringBoot构建项目
这里我使用的是idea,项目构建时习惯性勾上了Spring Web。
我们只需添加
SeimiCrawler
官方依赖即可。
这里我多加了一个
lombok
依赖,如果你没有使用过可以忽略,在实体类中增加构造方法即可。
<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>
二、编写代码
1.项目结构如下:
2.实体类:
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.爬虫类:
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©_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() {
String[] urls = new String[5];
for (int i = 0; i < 5; i++) {
urls[i] = baseUrl.replace("{pageNum}", i + 1 + "");
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();
String result = content.substring(content.indexOf("\"result\":") + 9, content.indexOf(",\"show_column\""));
logger.info("{}", result);
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");
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();
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<>();
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);
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.properties
配置文件:
#启动SeimiCrawler
seimi.crawler.enabled=true
seimi.crawler.names=bili
5.ffmpeg.exe文件:
你可以去ffmpeg官网下载,下方可以选择Windows并下载exe文件。
三、核心代码解析
1.@Crawler注解
该注解,我只写了一个name属性,用于和配置文件中的name对应,表示启动这个爬虫类。
2.属性参数
这里的三个属性,分别用于设置爬取资源保存路径、起始页面基路径、全局Cookie,你也可以考虑在配置文件中配置然后注入值。
这里着重讲解起始页面基路径:获取起始路径是学习爬虫的关键之一,这里我略讲路径获取过程:
首先我看上了B站音乐区:
然后准备爬取热榜:
此处视频时延迟加载:
分析network找到请求路径,点击翻页瞬间就找到了:
将请求拿到浏览器直接访问,我们要的数据就在result里面了:
至此,地址我们已经拿到了,找到分页参数,我将它替换成了{pageNum}
方便后续进行字符串替换。
3.核心方法
这里我们继承BaseSeimiCrawler
之后重写了两个方法:
其中startUrls
用户返回所要爬取的地址,我这里通过遍历生成了前5页的地址,你可可以自行修改,或者通过配置文件配置。
需要注意的是,这里我用了原生Jsoup获取了Cookie:
其中start
方法用于开始爬虫,这里通过字符串切割获取需要的json部分然后解析成实体集合:
需要注意的是,这里创建请求对象时,指定的callBack就是我下方编写的处理方法名,Seimi会自动帮我发送请求,并用指定方法获取响应,此过程有消息队列,多线程异步:
4.其它方法
getVideoAndAudioUrl
方法获取视频和音频地址是关键,B站较新的视频都是视频音频分离的,网页源码中可以找到地址,比较老的视频只有一个视频,获取方式不一样。另外不同分区略有差别,特别是电影区,这部分的优化我会在后续的博客中更新。
既然音频视频分离,那就一定要合并音频视频,所以有了mergeVideoAndAudio
方法,这里调用了FFmpeg实现了合并,效率极高。FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。这里不再对FFmpeg进行赘述。
四、运行结果
直接运行SpringBoot启动类即可,为了让大家看到效果,我加了大量日志,你可以选择关闭日志。
本地文件:
每个目录下都有三个文件,分别是纯音频、纯视频和合并后的视频:
五、特别声明
本文旨在使用实例讲解SeimiCrawler爬虫,并非针对B站。
本代码仅供学习使用,所爬取视频不得用于商业用途,请务必在24小时内删除!