初始化仓库
This commit is contained in:
parent
ecf8cd42b3
commit
ca8c0c6e45
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
config/*
|
||||
dist
|
||||
log/*
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"deno.enable": true,
|
||||
"deno.lint": true,
|
||||
"deno.unstable": false
|
||||
}
|
60
src/APIServer.ts
Normal file
60
src/APIServer.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { EventEmitter, WebSocketClient, WebSocketServer } from "./deps.ts";
|
||||
import { sendDanmaku } from './SendDanmaku.ts'
|
||||
import config from "./config.ts";
|
||||
|
||||
interface Message {
|
||||
cmd: string;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
data: any;
|
||||
}
|
||||
|
||||
const server = new WebSocketServer(config.api.port);
|
||||
const authedClientSet: Set<WebSocketClient> = new Set();
|
||||
|
||||
class APIMsgHandler extends EventEmitter {
|
||||
emit(
|
||||
eventName: string | symbol,
|
||||
socket: WebSocketClient,
|
||||
...args: unknown[]
|
||||
): boolean {
|
||||
if (eventName === "AUTH") {
|
||||
if (args[0] === config.api.token) {
|
||||
authedClientSet.add(socket);
|
||||
socket.send(JSON.stringify({ cmd: "AUTH", data: "AUTHED" }));
|
||||
return true;
|
||||
} else {
|
||||
socket.send(JSON.stringify({ cmd: "AUTH", data: "FAILED" }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (authedClientSet.has(socket)) {
|
||||
super.emit.apply(this, [eventName, socket, args]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const serverEventEmitter = new APIMsgHandler();
|
||||
|
||||
serverEventEmitter.on("SEND", (_socket: WebSocketClient, data: string) => {
|
||||
sendDanmaku({
|
||||
msg: data,
|
||||
});
|
||||
});
|
||||
|
||||
serverEventEmitter.on("ROOMID", (socket: WebSocket) => {
|
||||
socket.send(JSON.stringify({
|
||||
cmd: "ROOMID",
|
||||
data: config.room_id,
|
||||
}));
|
||||
});
|
||||
|
||||
server.on("connection", (client: WebSocketClient) => {
|
||||
client.on("message", (data: string) => {
|
||||
const msg: Message = JSON.parse(data);
|
||||
serverEventEmitter.emit(msg.cmd, client, msg.data);
|
||||
});
|
||||
});
|
||||
|
||||
export { server };
|
140
src/DanmakuReceiver.ts
Normal file
140
src/DanmakuReceiver.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { brotli, EventEmitter } from "./deps.ts";
|
||||
import config from "./config.ts";
|
||||
import { printLog } from "./utils/printLog.ts";
|
||||
import { server as apiServer } from './APIServer.ts'
|
||||
|
||||
enum DANMAKU_PROTOCOL {
|
||||
JSON = 0,
|
||||
HEARTBEAT,
|
||||
ZIP,
|
||||
BROTLI,
|
||||
}
|
||||
|
||||
enum DANMAKU_TYPE {
|
||||
HEARTBEAT = 2,
|
||||
HEARTBEAT_REPLY = 3,
|
||||
DATA = 5,
|
||||
AUTH = 7,
|
||||
AUTH_REPLY = 8,
|
||||
}
|
||||
|
||||
const cookie =
|
||||
`buvid3=${config.verify.buvid3};SESSDATA=${config.verify.sessdata};bili_jct=${config.verify.csrf}`;
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
export class DanmakuReceiver extends EventEmitter {
|
||||
private roomId: number;
|
||||
private ws: WebSocket | null = null;
|
||||
constructor(roomId: number) {
|
||||
super();
|
||||
this.roomId = roomId;
|
||||
}
|
||||
public async connect() {
|
||||
const roomConfig = await (await fetch(
|
||||
`https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id=${this.roomId}&platform=pc&player=web`,
|
||||
{
|
||||
headers: {
|
||||
cookie,
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (X11 Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36",
|
||||
host: "api.live.bilibili.com",
|
||||
},
|
||||
},
|
||||
)).json();
|
||||
this.ws = new WebSocket(
|
||||
`wss://${roomConfig.data.host_server_list[0].host}:${
|
||||
roomConfig.data.host_server_list[0].wss_port
|
||||
}/sub`,
|
||||
);
|
||||
this.ws.onopen = () => {
|
||||
const payload = JSON.stringify({
|
||||
roomid: this.roomId,
|
||||
protover: 3,
|
||||
platform: "web",
|
||||
uid: config.verify.uid,
|
||||
key: roomConfig.data.token,
|
||||
});
|
||||
this.ws!.send(this.generatePacket(
|
||||
1,
|
||||
7,
|
||||
payload,
|
||||
));
|
||||
this.ws!.onmessage = this.danmakuProcesser.bind(this);
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.emit("closed");
|
||||
};
|
||||
}
|
||||
private generatePacket(
|
||||
protocol: number,
|
||||
type: number,
|
||||
payload: string,
|
||||
): ArrayBuffer {
|
||||
const payloadEncoded = encoder.encode(payload);
|
||||
const packetLength = 16 + payloadEncoded.length;
|
||||
const packet = new ArrayBuffer(packetLength);
|
||||
const packetArray = new Uint8Array(packet);
|
||||
const packetView = new DataView(packet);
|
||||
packetView.setInt32(0, packetLength); // 总长度
|
||||
packetView.setInt16(4, 16); // 头长度
|
||||
packetView.setUint16(6, protocol); // 协议类型
|
||||
packetView.setUint32(8, type); // 包类型
|
||||
packetView.setUint32(12, 1); // 一个常数
|
||||
packetArray.set(payloadEncoded, 16); //写入负载
|
||||
return packet;
|
||||
}
|
||||
private async danmakuProcesser(ev: MessageEvent<Blob>) {
|
||||
// 弹幕事件处理
|
||||
const msgPacket = await ev.data.arrayBuffer()
|
||||
const msgArray = new Uint8Array(msgPacket);
|
||||
const msg = new DataView(msgPacket);
|
||||
const packetProtocol = msg.getInt16(6);
|
||||
const packetType = msg.getInt32(8);
|
||||
const packetPayload: Uint8Array = msgArray.slice(16);
|
||||
let jsonData;
|
||||
switch (packetType) {
|
||||
case DANMAKU_TYPE.HEARTBEAT_REPLY:
|
||||
// 心跳包,不做处理
|
||||
break;
|
||||
case DANMAKU_TYPE.AUTH_REPLY:
|
||||
printLog("通过认证");
|
||||
// 认证通过,每30秒发一次心跳包
|
||||
setInterval(() => {
|
||||
const heartbeatPayload = "陈睿你妈死了";
|
||||
if (this.ws) {
|
||||
this.ws.send(this.generatePacket(1, 2, heartbeatPayload));
|
||||
}
|
||||
}, 30000);
|
||||
this.emit("connected");
|
||||
break;
|
||||
case DANMAKU_TYPE.DATA:
|
||||
switch (packetProtocol) {
|
||||
case DANMAKU_PROTOCOL.JSON:
|
||||
// 这些数据大都没用,但还是留着吧
|
||||
jsonData = JSON.parse(decoder.decode(packetPayload));
|
||||
this.emit(jsonData.cmd, jsonData.data);
|
||||
break;
|
||||
case DANMAKU_PROTOCOL.BROTLI: {
|
||||
const resultRaw = brotli.decompress(packetPayload);
|
||||
const result = new DataView(resultRaw.buffer);
|
||||
let offset = 0;
|
||||
while (offset < resultRaw.length) {
|
||||
const length = result.getUint32(offset);
|
||||
const packetData = resultRaw.slice(offset + 16, offset + length);
|
||||
const data = JSON.parse(decoder.decode(packetData));
|
||||
const cmd = data.cmd.split(":")[0];
|
||||
this.emit(cmd, data.info || data.data);
|
||||
apiServer.clients.forEach((client) => {
|
||||
client.send(JSON.stringify({ cmd, data: data.info || data.data }))
|
||||
})
|
||||
offset += length;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
printLog("什么鬼,没见过这种包");
|
||||
}
|
||||
}
|
||||
}
|
60
src/SendDanmaku.ts
Normal file
60
src/SendDanmaku.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import config from "./config.ts";
|
||||
|
||||
const cookie =
|
||||
`buvid3=${config.verify.buvid3}; SESSDATA=${config.verify.sessdata}; bili_jct=${config.verify.csrf};`;
|
||||
|
||||
export interface DanmakuStruct {
|
||||
color?: number;
|
||||
bubble?: number;
|
||||
msg: string;
|
||||
mode?: number;
|
||||
fontsize?: number;
|
||||
rnd?: number;
|
||||
roomid?: number;
|
||||
csrf?: string;
|
||||
csrf_token?: string;
|
||||
}
|
||||
|
||||
export function sendDanmaku(danmaku: DanmakuStruct) {
|
||||
if (danmaku.msg.length > 19) {
|
||||
sendDanmaku({
|
||||
msg: danmaku.msg.slice(0, 15),
|
||||
});
|
||||
setTimeout(() => {
|
||||
sendDanmaku({
|
||||
msg: danmaku.msg.slice(15, danmaku.msg.length),
|
||||
});
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
danmaku.rnd = new Date().getTime();
|
||||
if (!danmaku.color) {
|
||||
danmaku.color = 5816798;
|
||||
}
|
||||
if (!danmaku.bubble) {
|
||||
danmaku.bubble = 0;
|
||||
}
|
||||
if (!danmaku.mode) {
|
||||
danmaku.mode = 1;
|
||||
}
|
||||
if (!danmaku.fontsize) {
|
||||
danmaku.fontsize = 24;
|
||||
}
|
||||
danmaku.roomid = config.room_id as number;
|
||||
danmaku.csrf = danmaku.csrf_token = config.verify.csrf;
|
||||
const data = new FormData();
|
||||
for (const k in danmaku) {
|
||||
data.append(k, danmaku[k as keyof DanmakuStruct]!.toString());
|
||||
}
|
||||
fetch("https://api.live.bilibili.com/msg/send", {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
headers: {
|
||||
cookie: cookie,
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36",
|
||||
host: "api.live.bilibili.com",
|
||||
"Referer": "https://live.bilibili.com",
|
||||
}
|
||||
});
|
||||
}
|
14
src/Text.ts
Normal file
14
src/Text.ts
Normal file
@ -0,0 +1,14 @@
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
const Encoding = {
|
||||
UTF8: {
|
||||
getBytes (str: string) {
|
||||
return encoder.encode(str)
|
||||
},
|
||||
getText (buffer: Uint8Array) {
|
||||
return decoder.decode(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { Encoding }
|
35
src/config.ts
Normal file
35
src/config.ts
Normal file
@ -0,0 +1,35 @@
|
||||
interface DanmakuTemplate {
|
||||
live_start: string
|
||||
live_end: string
|
||||
gift: string
|
||||
gift_total: string
|
||||
guard: string
|
||||
sc: string
|
||||
advertisement: string
|
||||
}
|
||||
|
||||
interface Credential {
|
||||
sessdata: string
|
||||
csrf: string
|
||||
buvid3: string
|
||||
uid: number
|
||||
}
|
||||
|
||||
interface api_config {
|
||||
token: string
|
||||
port: number
|
||||
}
|
||||
|
||||
interface ConfigStruct {
|
||||
room_id: number
|
||||
verify: Credential
|
||||
danmakus: DanmakuTemplate
|
||||
cold_down_time: number
|
||||
advertiseing_cold_down: number
|
||||
api: api_config
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
const config: ConfigStruct = JSON.parse(decoder.decode(Deno.readFileSync(`config/${Deno.args[0]}`)));
|
||||
|
||||
export default config;
|
69
src/danmakuCallbacks.ts
Normal file
69
src/danmakuCallbacks.ts
Normal file
@ -0,0 +1,69 @@
|
||||
// deno-lint-ignore-file
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import config from './config.ts'
|
||||
import { sendDanmaku } from './SendDanmaku.ts'
|
||||
import { Encoding } from './Text.ts'
|
||||
import { getTimeString, FormatString } from './utils/mod.ts'
|
||||
let skipCount = 0
|
||||
let logFile = Deno.openSync(`./log/${getTimeString()}-${config.room_id}.log`, {
|
||||
create: true,
|
||||
write: true
|
||||
})
|
||||
const thanksColdDownSet = new Set<string>()
|
||||
|
||||
|
||||
export function receiveGift(data: any) {
|
||||
if(thanksColdDownSet.has(data.uname)){
|
||||
return
|
||||
}
|
||||
logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data.uname} 投喂了${data.super_gift_num}个 ${data.giftName} 价值${data.price / 1000 * data.super_gift_num}元\n`))
|
||||
sendDanmaku({
|
||||
msg: FormatString(config.danmakus.gift, { name: data.uname, gift: data.giftName })
|
||||
})
|
||||
thanksColdDownSet.add(data.uname)
|
||||
setTimeout(() => {thanksColdDownSet.delete(data.uname)}, config.cold_down_time)
|
||||
}
|
||||
|
||||
export function onTotalGift(data: any) {
|
||||
logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data.uname}投喂了${data.total_num}个${data.gift_name}\n`))
|
||||
sendDanmaku({
|
||||
msg: FormatString(config.danmakus.gift_total, { name: data.uname, gift: data.gift_name, count: data.total_num })
|
||||
})
|
||||
}
|
||||
|
||||
export function receiveDanmaku(data: any) {
|
||||
logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data[2][1]}:${data[2][0]} ${data[1]}\n`))
|
||||
}
|
||||
|
||||
export function onLiveStart() {
|
||||
if(skipCount != 1){
|
||||
skipCount ++
|
||||
return
|
||||
}
|
||||
skipCount = 0
|
||||
logFile.close()
|
||||
sendDanmaku({ msg: config.danmakus.live_start })
|
||||
logFile = Deno.openSync(`./log/${getTimeString()}-${config.room_id}.log`, {
|
||||
create: true
|
||||
})
|
||||
logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} 直播开始\n`))
|
||||
}
|
||||
|
||||
export function onLiveEnd() {
|
||||
logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} 直播结束\n`))
|
||||
sendDanmaku({ msg: config.danmakus.live_end })
|
||||
}
|
||||
|
||||
export function onGraud(data: any) {
|
||||
logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data.username}:${data.uid} 购买了 ${data.gift_name}\n`))
|
||||
sendDanmaku({
|
||||
msg: FormatString(config.danmakus.guard, { type: data.gift_name, name: data.username })
|
||||
})
|
||||
}
|
||||
|
||||
export function onSuperChat(data: any) {
|
||||
logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data.user_info.uname}发送了SC 价格${data.price}\n`))
|
||||
sendDanmaku({
|
||||
msg: FormatString(config.danmakus.sc, { name: data.user_info.uname })
|
||||
})
|
||||
}
|
7
src/deps.ts
Normal file
7
src/deps.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import * as events from 'https://deno.land/x/events@v1.0.0/mod.ts'
|
||||
|
||||
const EventEmitter = events.default
|
||||
export { EventEmitter }
|
||||
export * as brotli from 'https://deno.land/x/brotli@v0.1.4/mod.ts'
|
||||
export { WebSocketServer } from "https://deno.land/x/websocket@v0.1.4/mod.ts";
|
||||
export type { WebSocketClient } from "https://deno.land/x/websocket@v0.1.4/mod.ts";
|
20
src/main.ts
Normal file
20
src/main.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import config from './config.ts';
|
||||
import { DanmakuReceiver } from './DanmakuReceiver.ts';
|
||||
import { onGraud, onLiveEnd, onLiveStart, onSuperChat, onTotalGift, receiveDanmaku, receiveGift } from './danmakuCallbacks.ts';
|
||||
import { printLog } from './utils/mod.ts';
|
||||
|
||||
const danmakuReceiver = new DanmakuReceiver(config.room_id);
|
||||
danmakuReceiver.on('connected', () => {
|
||||
printLog('连接成功');
|
||||
});
|
||||
|
||||
danmakuReceiver.on('closed', () => danmakuReceiver.connect());
|
||||
danmakuReceiver.on('SEND_GIFT', receiveGift);
|
||||
danmakuReceiver.on('LIVE', onLiveStart);
|
||||
danmakuReceiver.on('PREPARING', onLiveEnd);
|
||||
danmakuReceiver.on('DANMU_MSG', receiveDanmaku);
|
||||
danmakuReceiver.on('COMBO_SEND', onTotalGift);
|
||||
danmakuReceiver.on('GUARD_BUY', onGraud);
|
||||
danmakuReceiver.on('SUPER_CHAT_MESSAGE', onSuperChat);
|
||||
|
||||
danmakuReceiver.connect();
|
8
src/utils/FormatString.ts
Normal file
8
src/utils/FormatString.ts
Normal file
@ -0,0 +1,8 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function FormatString(str: string, args: any): string {
|
||||
let result: string = str;
|
||||
for(const arg in args) {
|
||||
result = result.replace(new RegExp(`{${arg}}`, 'g'), args[arg])
|
||||
}
|
||||
return result;
|
||||
}
|
11
src/utils/getTimeString.ts
Normal file
11
src/utils/getTimeString.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function getTimeString(): string {
|
||||
const time = new Date()
|
||||
const year = time.getFullYear()
|
||||
const month = time.getMonth() + 1
|
||||
const day = time.getDate()
|
||||
const hour = time.getHours()
|
||||
const minutes = time.getMinutes()
|
||||
const second = time.getSeconds()
|
||||
|
||||
return `${year}-${month}-${day}-${hour}-${minutes}-${second}`
|
||||
}
|
3
src/utils/mod.ts
Normal file
3
src/utils/mod.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { getTimeString } from './getTimeString.ts'
|
||||
export { printLog } from './printLog.ts'
|
||||
export { FormatString } from './FormatString.ts'
|
4
src/utils/printLog.ts
Normal file
4
src/utils/printLog.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { getTimeString } from './getTimeString.ts'
|
||||
export function printLog(msg: unknown) {
|
||||
console.log(`[${getTimeString()}] ${msg}`)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user