教程
B站直播间弹幕Websocket获取 --- 哔哩哔哩直播开放平台
基于B站直播开放平台开放且未上架时,只能个人使用。
代码实现
1、相关依赖
fastjson2用于解析JSON字符串 ,可自行替换成别的框架。
hutool-core用于解压zip数据,可自行替换成别的框架。
xml
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.40</version>
</dependency>
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-core -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>5.8.21</version>
</dependency>
1、新建ProjectRequest.java
用于发送项目start、end、heartbeat请求。
注意:
没有上架的项目,start返回结果没有场次ID,导致end、heartbeat请求不能正常执行。
但是没有关系,start能获得弹幕服务信息就行。
java
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import jakarta.annotation.Nonnull;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
public class ProjectRequest {
/**
* 项目ID
*/
private long appId;
/**
* 身份验证Key
*/
private String accessKey;
/**
* 身份验证密钥
*/
private String accessSecret;
public ProjectRequest(long appId, String accessKey, String accessSecret) {
this.appId = appId;
this.accessKey = accessKey;
this.accessSecret = accessSecret;
}
public final static String START_URL = "https://live-open.biliapi.com/v2/app/start";
public final static String END_URL = "https://live-open.biliapi.com/v2/app/end";
public final static String HEART_BEAT_URL = "https://live-open.biliapi.com/v2/app/heartbeat";
public final static String BATCH_HEART_BEAT_URL = "https://live-open.biliapi.com/v2/app/batchHeartbeat";
/**
* 接口描述:开启项目第一步,平台会根据入参进行鉴权校验。鉴权通过后,返回长连信息、场次信息和主播信息。开发者拿到长连和心跳信息后,需要参照[长连说明]和[项目心跳],与平台保持健康的
* @param code 必填 string [主播身份码]
* param appId 必填 integer(13位长度的数值,注意不要用普通int,会溢出的) 项目ID
*/
public String start(String code) throws IOException, NoSuchAlgorithmException, InvalidKeyException {
Map<String,Object> params = new HashMap<>();
params.put("code", code);
params.put("app_id", appId);
return post(START_URL, params);
}
/**
* 接口描述:项目关闭时需要主动调用此接口,使用对应项目Id及项目开启时返回的game_id作为唯一标识,调用后会同步下线互动道具等内容,项目关闭后才能进行下一场次互动。
* param appId 必填 integer(13位长度的数值,注意不要用普通int,会溢出的) 项目ID
* param gameId 必填 场次id
*/
public String end(String gameId) throws IOException, NoSuchAlgorithmException, InvalidKeyException {
Map<String,Object> params = new HashMap<>();
params.put("game_id", gameId);
params.put("app_id", appId);
return post(END_URL, params);
}
/**
* 接口描述:项目开启后,需要持续间隔20秒调用一次该接口。平台超过60s未收到项目心跳,会自动关闭当前场次(game_id),同时将道具相关功能下线,以确保下一场次项目正常运行。
* 接口地址:/v2/app/heartbeat
* 方法:POST
* param gameId 必填 场次id
*/
public String heartbeat(String gameId) throws IOException, NoSuchAlgorithmException, InvalidKeyException {
Map<String,Object> params = new HashMap<>();
params.put("game_id", gameId);
return post(HEART_BEAT_URL, params);
}
/**
* 项目批量心跳
* 接口地址:/v2/app/batchHeartbeat
* 方法:POST
* @param gameIds 必填 []string 场次id
* */
public String batchHeartbeat(@Nonnull List<String> gameIds) throws IOException, NoSuchAlgorithmException, InvalidKeyException {
Map<String,Object> params = new HashMap<>();
params.put("game_ids", JSONArray.toJSONString(gameIds));
return post(HEART_BEAT_URL, params);
}
/**
* 自定义post请求
* @param url
* @param dataMap
* @throws IOException
* @throws NoSuchAlgorithmException
* @throws InvalidKeyException
*/
private String post(String url, Map<String,Object> dataMap) throws IOException, NoSuchAlgorithmException, InvalidKeyException {
String bodyStr = JSONObject.toJSONString(dataMap);
HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection();
con.setRequestMethod("POST");
// 设置请求头
setHeader(con, bodyStr);
// 发送 POST 请求
con.setDoOutput(true);
try(DataOutputStream wr = new DataOutputStream(con.getOutputStream())) {
wr.writeBytes(bodyStr);
wr.flush();
}
// 获取响应结果
try(BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8))){
// 返回响应结果
return bufferedReader.lines().collect(Collectors.joining("\n"));
}
}
public static String KEY_CONTENT_MD5 = "x-bili-content-md5";
public static String KEY_TIMESTAMP = "x-bili-timestamp";
public static String KEY_SIGNATURE_NONCE = "x-bili-signature-nonce";
/**
* 设置请求头
* @param con
* @param bodyStr 请求体
* @throws NoSuchAlgorithmException
* @throws InvalidKeyException
*/
private void setHeader(HttpURLConnection con,String bodyStr) throws NoSuchAlgorithmException, InvalidKeyException {
con.setRequestProperty("User-Agent", "Mozilla/5.0");
/**----------------------------------------------------------------------------**/
//必填:接受的返回结果的类型。目前只支持JSON类型,取值:application/json。
con.setRequestProperty("Accept", "application/json");
//必填:当前请求体(Request Body)的数据类型。目前只支持JSON类型,取值:application/json。
con.setRequestProperty("Content-Type", "application/json");
//必填:请求体的编码值,根据请求体计算所得。算法说明:将请求体内容当作字符串进行MD5编码。
con.setRequestProperty(KEY_CONTENT_MD5, getContentMd5(bodyStr));
//必填:unix时间戳,单位是秒。请求时间戳不能超过当前时间10分钟,否则请求会被丢弃。
con.setRequestProperty(KEY_TIMESTAMP, String.valueOf(System.currentTimeMillis()/1000));
//必填: 版本1.0
con.setRequestProperty("x-bili-signature-version", "1.0");
//必填:签名唯一随机数。用于防止网络重放攻击,建议您每一次请求都使用不同的随机数
con.setRequestProperty(KEY_SIGNATURE_NONCE, UUID.randomUUID().toString());
//必填:加密算法
con.setRequestProperty("x-bili-signature-method", "HMAC-SHA256");
//必填: accesskey id
con.setRequestProperty("x-bili-accesskeyid", accessKey);
//必填:请求签名(注意生成的签名是小写的)。关于请求签名的计算方法,请参见签名机制
con.setRequestProperty("Authorization", generateSignature(con));
}
/**
* MD5计算
*/
private String getContentMd5(String content) throws NoSuchAlgorithmException {
MessageDigest md5 = MessageDigest.getInstance("MD5");
return byte2Hex( md5.digest(content.getBytes(StandardCharsets.UTF_8)) );
}
/**
* 签名 HmacSHA256计算
*/
public String generateSignature(HttpURLConnection con) throws NoSuchAlgorithmException, InvalidKeyException {
StringBuilder s = new StringBuilder();
s.append("x-bili-accesskeyid:").append(accessKey).append("\n");
s.append("x-bili-content-md5:").append(con.getRequestProperty(KEY_CONTENT_MD5)).append("\n");
s.append("x-bili-signature-method:").append("HMAC-SHA256").append("\n");
s.append("x-bili-signature-nonce:").append(con.getRequestProperty(KEY_SIGNATURE_NONCE)).append("\n");
s.append("x-bili-signature-version:").append("1.0").append("\n");
s.append("x-bili-timestamp:").append(con.getRequestProperty(KEY_TIMESTAMP));
byte[] headerByte = s.toString().getBytes(StandardCharsets.UTF_8);
byte[] secretByte = accessSecret.getBytes(StandardCharsets.UTF_8);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretByte, "HmacSHA256"));
byte[] bytes = mac.doFinal(headerByte);
return byte2Hex(bytes);
}
/**
* 字节数组转16进制字符串
* @param bytes
* @return
*/
private static String byte2Hex(byte[] bytes){
StringBuffer stringBuffer = new StringBuffer();
String temp = null;
for (int i=0;i<bytes.length;i++){
temp = Integer.toHexString(bytes[i] & 0xFF);
if (temp.length()==1){
//1得到一位的进行补0操作
stringBuffer.append("0");
}
stringBuffer.append(temp);
}
return stringBuffer.toString();
}
}
3、新建 WebsocketListener.java
用于监听接收到的数据。
java
import jakarta.websocket.*;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import cn.hutool.core.util.ZipUtil;
@ClientEndpoint
public class WebsocketListener {
private Session session;
private String authBody;
public WebsocketListener(String authBody) {
this.authBody = authBody;
}
@OnOpen
public void onOpen(Session session) throws IOException {
System.out.println("已连接服务...");
this.session = session;
RemoteEndpoint.Async remote = session.getAsyncRemote();
//鉴权协议包
ByteBuffer authPack = ByteBuffer.wrap(generateAuthPack(authBody));
remote.sendBinary(authPack);
//每30秒发送心跳包
ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
executorService.scheduleAtFixedRate(() -> {
try {
ByteBuffer heartBeatPack = ByteBuffer.wrap(generateHeartBeatPack());
remote.sendBinary(heartBeatPack);
} catch (IOException e) {
throw new RuntimeException(e);
}
}, 0, 30, TimeUnit.SECONDS);
}
@OnMessage
public void onMessage(ByteBuffer byteBuffer) {
//解包
unpack(byteBuffer);
}
@OnClose
public void onClose(Session session, CloseReason closeReason) {
System.out.println("关闭Websocket服务: " + closeReason);
}
@OnError
public void onError(Session session, Throwable t) {
System.out.println("Websocket服务异常: " + t.getMessage());
}
public interface Opt{
short HEARTBEAT = 2;// 客户端发送的心跳包(30秒发送一次)
short HEARTBEAT_REPLY = 3;// 服务器收到心跳包的回复 人气值,数据不是JSON,是4字节整数
short SEND_SMS_REPLY = 5;// 服务器推送的弹幕消息包
short AUTH = 7;//客户端发送的鉴权包(客户端发送的第一个包)
short AUTH_REPLY = 8;//服务器收到鉴权包后的回复
}
public interface Version{
short NORMAL = 0;//Body实际发送的数据------普通JSON数据
short ZIP = 2; //Body中是经过压缩后的数据,请使用zlib解压,然后按照Proto协议去解析。
}
/**
* 封包
* @param jsonStr 数据
* @param code 协议包类型
* @return
* @throws IOException
*/
public static byte[] pack(String jsonStr, short code) throws IOException {
byte[] contentBytes = new byte[0];
if(Opt.AUTH == code){
contentBytes = jsonStr.getBytes();
}
try(ByteArrayOutputStream data = new ByteArrayOutputStream();
DataOutputStream stream = new DataOutputStream(data)){
stream.writeInt(contentBytes.length + 16);//封包总大小
stream.writeShort(16);//头部长度 header的长度,固定为16
stream.writeShort(Version.NORMAL);
stream.writeInt(code);//操作码(封包类型)
stream.writeInt(1);//保留字段,可以忽略。
if(Opt.AUTH == code){
stream.writeBytes(jsonStr);
}
return data.toByteArray();
}
}
/**
* 生成认证包
* @return
*/
public static byte[] generateAuthPack(String jsonStr) throws IOException {
return pack(jsonStr, Opt.AUTH);
}
/**
* 生成心跳包
* @return
*/
public static byte[] generateHeartBeatPack() throws IOException {
return pack(null, Opt.HEARTBEAT);
}
/**
* 解包
* @param byteBuffer
* @return
*/
public static void unpack(ByteBuffer byteBuffer){
int packageLen = byteBuffer.getInt();
short headLength = byteBuffer.getShort();
short protVer = byteBuffer.getShort();
int optCode = byteBuffer.getInt();
int sequence = byteBuffer.getInt();
if(Opt.HEARTBEAT_REPLY == optCode){
System.out.println("这是服务器心跳回复");
}
byte[] contentBytes = new byte[packageLen - headLength];
byteBuffer.get(contentBytes);
//如果是zip包就进行解包
if(Version.ZIP == protVer){
unpack(ByteBuffer.wrap(ZipUtil.unZlib(contentBytes)));
return;
}
String content = new String(contentBytes, StandardCharsets.UTF_8);
if(Opt.AUTH_REPLY == optCode){
//返回{"code":0}表示成功
System.out.println("这是鉴权回复:"+content);
}
//真正的弹幕消息
if(Opt.SEND_SMS_REPLY == optCode){
System.out.println("真正的弹幕消息:"+content);
// todo 自定义处理
}
//只存在ZIP包解压时才有的情况
//如果byteBuffer游标 小于 byteBuffer大小,那就证明还有数据
if(byteBuffer.position() < byteBuffer.limit()){
unpack(byteBuffer);
}
}
}
4、使用
java
public static void main(String[] args) throws IOException, NoSuchAlgorithmException, InvalidKeyException, URISyntaxException, DeploymentException {
ProjectRequest p = new ProjectRequest(你的应用ID, 你的Access_key, 你的 Access_Secret);
//获取弹幕服务信息
String result = p.start(你的身份码);
JSONObject data = JSONObject.parseObject(result).getJSONObject("data");
//个人信息
JSONObject anchorInfo = data.getJSONObject("anchor_info");
//弹幕服务器信息
JSONObject websocketInfo = data.getJSONObject("websocket_info");
//弹幕服务器地址
JSONArray wssLinks = websocketInfo.getJSONArray("wss_link");
//websocket鉴权信息
String authBody = websocketInfo.getString("auth_body");
//选一个服务器节点
String uri = wssLinks.getString(0);
WebSocketContainer container = ContainerProvider.getWebSocketContainer();
// 连接到WebSocket服务器
container.connectToServer(new WebsocketListener(authBody), new URI(uri));
}
参数获取 | |
---|---|
Access_key 和 Access_Secret | 去B站直播开放平台注册申请个人开发者后就能获得 |
应用ID | 成为个人开发者后,在直播开放平台创建应用后,就能获得应用ID |
身份码 | 登录B站直播间找到幻星-互动玩法,在里面就能找到身份码 |