Compare commits

..

35 Commits

Author SHA1 Message Date
John Smith
b73a4e3748 更新版本号v1.4.1 2020-03-01 14:22:12 +08:00
John Smith
102746d0b0 添加显示礼物名、只合并同类礼物 2020-03-01 14:14:19 +08:00
John Smith
2d237af6fd 提示用户已加载页面 2020-02-29 19:20:29 +08:00
John Smith
4030ff43cd 减小编译后文件尺寸 2020-02-22 17:21:47 +08:00
John Smith
092cc8d9ab 更新帮助 2020-02-06 20:31:43 +08:00
John Smith
2d7106ae93 更新版本号v1.4.0 2020-02-06 19:53:33 +08:00
John Smith
d5b885aab4 添加前端获取后端配置 2020-02-06 19:51:03 +08:00
John Smith
34f0effb6a 修复翻译导致弹幕无法滚动到底部的问题 2020-02-06 18:31:05 +08:00
John Smith
d3e9300fa4 添加自动翻译前端 2020-02-06 17:39:56 +08:00
John Smith
0c9560f8ca 添加自动翻译后端 2020-02-06 12:48:20 +08:00
John Smith
1c5df01fbc websocket消息优化 2020-02-05 17:28:10 +08:00
John Smith
49cdc56cff 防止队列中出现相同uid时重复获取头像 2020-02-05 12:58:26 +08:00
John Smith
00e1f31f84 优化前端防卡代码 2020-02-04 20:33:29 +08:00
John Smith
b7e06652b4 防止前端弹幕卡住 2020-02-04 20:13:11 +08:00
John Smith
0cf49ee042 防止更新版本后浏览器加载缓存 2020-02-04 16:26:28 +08:00
John Smith
7d610ecfdc 修复获取头像future没有设置结果的问题、部分头像URL没有处理的问题 2020-02-04 16:12:16 +08:00
John Smith
c8e4278c39 修复被B站ban后不会恢复获取头像的问题 2020-02-03 19:29:29 +08:00
John Smith
07b47a26ca 更新版本号v1.3.0
close #15
2020-02-03 16:23:55 +08:00
John Smith
8d55331e6c 头像缓存持久化 2020-02-03 16:18:21 +08:00
John Smith
cae06858fc 前端配置放在URL参数 2020-02-01 21:27:50 +08:00
John Smith
8d4e8e6f35 一些前端优化 2020-02-01 18:17:23 +08:00
John Smith
8d40f9f9e5 更新版本号 2020-01-12 23:02:15 +08:00
John Smith
e93f6b2383 兼容B站弹幕升级 2020-01-12 22:46:02 +08:00
John Smith
4c2e216191 添加帮助 2019-12-16 23:40:01 +08:00
John Smith
2a35541dc5 捕获write_message异常 2019-12-08 17:55:58 +08:00
John Smith
a927282e77 修复弹幕太快时消息平滑失效的问题 2019-12-01 16:36:50 +08:00
John Smith
a7faac5425 修复有时消息不能正确合并的问题 2019-11-30 23:57:52 +08:00
John Smith
a22496c355 修复固定消息的顺序 2019-11-30 23:24:06 +08:00
John Smith
20ec58b965 Merge pull request #13 from simon300000/https-face
auto http/https avatarUrl
2019-11-30 14:26:20 +08:00
simon3000
7453d7e890 (python) http/https avatarUrl 2019-11-30 07:20:35 +01:00
simon3000
1991e33b9d Revert "auto http/https avatarUrl"
This reverts commit 4b98c56965.
2019-11-30 07:16:38 +01:00
John Smith
f429a6a03d Merge pull request #12 from simon300000/generate-https
roomUrl auto https support
2019-11-30 14:16:28 +08:00
simon3000
4b98c56965 auto http/https avatarUrl 2019-11-30 07:08:17 +01:00
simon3000
eb43a37ae5 roomUrl auto https support 2019-11-30 06:56:05 +01:00
John Smith
1cf6d88460 各种优化 2019-11-29 23:48:43 +08:00
46 changed files with 6327 additions and 12006 deletions

1
.gitignore vendored
View File

@@ -105,3 +105,4 @@ venv.bak/
.idea/
data/database.db

View File

@@ -1,7 +1,7 @@
# blivechat
用于OBS的仿YouTube风格的bilibili直播评论栏
最近喜欢看VTuber想为此写些程序于是有了这个东西。~~写到一半发现有类似项目了:[bilibili-live-chat](https://github.com/Tsuk1ko/bilibili-live-chat)、[BiliChat](https://github.com/3Shain/BiliChat)~~ 本项目就当做练手吧而且对YouTube的模仿程度更高
最近喜欢看VTuber想为此写些程序于是有了这个东西。~~写到一半发现有类似项目了:[bilibili-live-chat](https://github.com/Tsuk1ko/bilibili-live-chat)、[BiliChat](https://github.com/3Shain/BiliChat)~~
![OBS截图](https://github.com/xfgryujk/blivechat/blob/master/screenshots/obs.png)
![Chrome截图](https://github.com/xfgryujk/blivechat/blob/master/screenshots/chrome.png)
@@ -11,8 +11,9 @@
* 兼容YouTube直播评论栏的样式
* 金瓜子礼物模仿醒目留言显示
* 高亮舰队、房管、主播的用户名
* 支持屏蔽弹幕、限制最大速度等设置
* 支持屏蔽弹幕、合并相似弹幕等设置
* 自带样式生成器
* 支持自动翻译弹幕、醒目留言到日语
## 使用方法
### 本地使用
@@ -23,7 +24,7 @@
```
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入房间ID保存配置复制房间URL
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS或者可以在首页的样式设置里输入CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
### 公共服务器
请优先在本地使用,使用公共服务器会有更大的弹幕延迟,而且服务器故障时可能出现直播事故

463
api/chat.py Normal file
View File

@@ -0,0 +1,463 @@
# -*- coding: utf-8 -*-
import asyncio
import enum
import json
import logging
import random
import time
import uuid
from typing import *
import aiohttp
import tornado.websocket
import blivedm.blivedm as blivedm
import config
import models.avatar
import models.translate
logger = logging.getLogger(__name__)
class Command(enum.IntEnum):
HEARTBEAT = 0
JOIN_ROOM = 1
ADD_TEXT = 2
ADD_GIFT = 3
ADD_MEMBER = 4
ADD_SUPER_CHAT = 5
DEL_SUPER_CHAT = 6
UPDATE_TRANSLATION = 7
_http_session = aiohttp.ClientSession()
room_manager: Optional['RoomManager'] = None
def init():
global room_manager
room_manager = RoomManager()
class Room(blivedm.BLiveClient):
# 重新定义parse_XXX是为了减少对字段名的依赖防止B站改字段名
def __parse_danmaku(self, command):
info = command['info']
if info[3]:
room_id = info[3][3]
medal_level = info[3][0]
else:
room_id = medal_level = 0
return self._on_receive_danmaku(blivedm.DanmakuMessage(
None, None, None, info[0][4], None, None, info[0][9], None,
info[1],
info[2][0], info[2][1], info[2][2], None, None, info[2][5], info[2][6], None,
medal_level, None, None, room_id, None, None,
info[4][0], None, None,
None, None,
info[7]
))
def __parse_gift(self, command):
data = command['data']
return self._on_receive_gift(blivedm.GiftMessage(
data['giftName'], data['num'], data['uname'], data['face'], None,
data['uid'], data['timestamp'], None, None,
None, None, None, data['coin_type'], data['total_coin']
))
def __parse_buy_guard(self, command):
data = command['data']
return self._on_buy_guard(blivedm.GuardBuyMessage(
data['uid'], data['username'], None, None, None,
None, None, data['start_time'], None
))
def __parse_super_chat(self, command):
data = command['data']
return self._on_super_chat(blivedm.SuperChatMessage(
data['price'], data['message'], None, data['start_time'],
None, None, data['id'], None,
None, data['uid'], data['user_info']['uname'],
data['user_info']['face'], None,
None, None,
None, None, None,
None
))
_COMMAND_HANDLERS = {
**blivedm.BLiveClient._COMMAND_HANDLERS,
'DANMU_MSG': __parse_danmaku,
'SEND_GIFT': __parse_gift,
'GUARD_BUY': __parse_buy_guard,
'SUPER_CHAT_MESSAGE': __parse_super_chat
}
def __init__(self, room_id):
super().__init__(room_id, session=_http_session, heartbeat_interval=10)
self.clients: List['ChatHandler'] = []
self.auto_translate_count = 0
def stop_and_close(self):
if self.is_running:
future = self.stop()
future.add_done_callback(lambda _future: asyncio.ensure_future(self.close()))
else:
asyncio.ensure_future(self.close())
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
for client in self.clients:
try:
client.write_message(body)
except tornado.websocket.WebSocketClosedError:
pass
def send_message_if(self, can_send_func: Callable[['ChatHandler'], bool], cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
for client in filter(can_send_func, self.clients):
try:
client.write_message(body)
except tornado.websocket.WebSocketClosedError:
pass
async def _on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
asyncio.ensure_future(self.__on_receive_danmaku(danmaku))
async def __on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
if danmaku.uid == self.room_owner_uid:
author_type = 3 # 主播
elif danmaku.admin:
author_type = 2 # 房管
elif danmaku.privilege_type != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
need_translate = self._need_translate(danmaku.msg)
if need_translate:
translation = models.translate.get_translation_from_cache(danmaku.msg)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
id_ = uuid.uuid4().hex
# 为了节省带宽用list而不是dict
self.send_message(Command.ADD_TEXT, [
# 0: avatarUrl
await models.avatar.get_avatar_url(danmaku.uid),
# 1: timestamp
danmaku.timestamp,
# 2: authorName
danmaku.uname,
# 3: authorType
author_type,
# 4: content
danmaku.msg,
# 5: privilegeType
danmaku.privilege_type,
# 6: isGiftDanmaku
1 if danmaku.msg_type else 0,
# 7: authorLevel
danmaku.user_level,
# 8: isNewbie
1 if danmaku.urank < 10000 else 0,
# 9: isMobileVerified
1 if danmaku.mobile_verify else 0,
# 10: medalLevel
0 if danmaku.room_id != self.room_id else danmaku.medal_level,
# 11: id
id_,
# 12: translation
translation
])
if need_translate:
await self._translate_and_response(danmaku.msg, id_)
async def _on_receive_gift(self, gift: blivedm.GiftMessage):
avatar_url = models.avatar.process_avatar_url(gift.face)
models.avatar.update_avatar_cache(gift.uid, avatar_url)
if gift.coin_type != 'gold': # 丢人
return
id_ = uuid.uuid4().hex
self.send_message(Command.ADD_GIFT, {
'id': id_,
'avatarUrl': avatar_url,
'timestamp': gift.timestamp,
'authorName': gift.uname,
'totalCoin': gift.total_coin,
'giftName': gift.gift_name,
'num': gift.num
})
async def _on_buy_guard(self, message: blivedm.GuardBuyMessage):
asyncio.ensure_future(self.__on_buy_guard(message))
async def __on_buy_guard(self, message: blivedm.GuardBuyMessage):
id_ = uuid.uuid4().hex
self.send_message(Command.ADD_MEMBER, {
'id': id_,
'avatarUrl': await models.avatar.get_avatar_url(message.uid),
'timestamp': message.start_time,
'authorName': message.username
})
async def _on_super_chat(self, message: blivedm.SuperChatMessage):
avatar_url = models.avatar.process_avatar_url(message.face)
models.avatar.update_avatar_cache(message.uid, avatar_url)
need_translate = self._need_translate(message.message)
if need_translate:
translation = models.translate.get_translation_from_cache(message.message)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
id_ = str(message.id)
self.send_message(Command.ADD_SUPER_CHAT, {
'id': id_,
'avatarUrl': avatar_url,
'timestamp': message.start_time,
'authorName': message.uname,
'price': message.price,
'content': message.message,
'translation': translation
})
if need_translate:
asyncio.ensure_future(self._translate_and_response(message.message, id_))
async def _on_super_chat_delete(self, message: blivedm.SuperChatDeleteMessage):
self.send_message(Command.ADD_SUPER_CHAT, {
'ids': list(map(str, message.ids))
})
def _need_translate(self, text):
return (
config.get_config().enable_translate
and self.auto_translate_count > 0
and models.translate.need_translate(text)
)
async def _translate_and_response(self, text, msg_id):
translation = await models.translate.translate(text)
if translation is None:
return
self.send_message_if(
lambda client: client.auto_translate,
Command.UPDATE_TRANSLATION,
[
# 0: id
msg_id,
# 1: translation
translation
]
)
class RoomManager:
def __init__(self):
self._rooms: Dict[int, Room] = {}
async def add_client(self, room_id, client: 'ChatHandler'):
if room_id not in self._rooms:
if not await self._add_room(room_id):
client.close()
return
room = self._rooms[room_id]
room.clients.append(client)
logger.info('%d clients in room %s', len(room.clients), room_id)
if client.auto_translate:
room.auto_translate_count += 1
if client.application.settings['debug']:
await client.send_test_message()
def del_client(self, room_id, client: 'ChatHandler'):
if room_id not in self._rooms:
return
room = self._rooms[room_id]
room.clients.remove(client)
logger.info('%d clients in room %s', len(room.clients), room_id)
if client.auto_translate:
room.auto_translate_count -= 1
if not room.clients:
self._del_room(room_id)
async def _add_room(self, room_id):
if room_id in self._rooms:
return True
logger.info('Creating room %d', room_id)
room = Room(room_id)
self._rooms[room_id] = room
if await room.init_room():
room.start()
return True
else:
self._del_room(room_id)
return False
def _del_room(self, room_id):
if room_id not in self._rooms:
return
logger.info('Removing room %d', room_id)
room = self._rooms[room_id]
for client in room.clients:
client.close()
room.stop_and_close()
del self._rooms[room_id]
# noinspection PyAbstractClass
class ChatHandler(tornado.websocket.WebSocketHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._close_on_timeout_future = None
self.room_id = None
self.auto_translate = False
def open(self):
logger.info('Websocket connected %s', self.request.remote_ip)
self._close_on_timeout_future = asyncio.ensure_future(self._close_on_timeout())
async def _close_on_timeout(self):
try:
# 超过一定时间还没加入房间则断开
await asyncio.sleep(10)
logger.warning('Client %s joining room timed out', self.request.remote_ip)
self.close()
except (asyncio.CancelledError, tornado.websocket.WebSocketClosedError):
pass
def on_message(self, message):
try:
body = json.loads(message)
cmd = body['cmd']
if cmd == Command.HEARTBEAT:
return
elif cmd == Command.JOIN_ROOM:
if self.has_joined_room:
return
self.room_id = int(body['data']['roomId'])
logger.info('Client %s is joining room %d', self.request.remote_ip, self.room_id)
try:
cfg = body['data']['config']
self.auto_translate = cfg['autoTranslate']
except KeyError:
pass
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
self._close_on_timeout_future.cancel()
self._close_on_timeout_future = None
else:
logger.warning('Unknown cmd, client: %s, cmd: %d, body: %s', self.request.remote_ip, cmd, body)
except:
logger.exception('on_message error, client: %s, message: %s', self.request.remote_ip, message)
def on_close(self):
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id))
if self.has_joined_room:
room_manager.del_client(self.room_id, self)
if self._close_on_timeout_future is not None:
self._close_on_timeout_future.cancel()
self._close_on_timeout_future = None
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
return True
return super().check_origin(origin)
# 测试用
async def send_test_message(self):
base_data = {
'avatarUrl': await models.avatar.get_avatar_url(300474),
'timestamp': time.time(),
'authorName': 'xfgryujk',
}
text_data = [
# 0: avatarUrl
base_data['avatarUrl'],
# 1: timestamp
base_data['timestamp'],
# 2: authorName
base_data['authorName'],
# 3: authorType
0,
# 4: content
'我能吞下玻璃而不伤身体',
# 5: privilegeType
0,
# 6: isGiftDanmaku
0,
# 7: authorLevel
20,
# 8: isNewbie
0,
# 9: isMobileVerified
1,
# 10: medalLevel
0,
# 11: id
uuid.uuid4().hex,
# 12: translation
''
]
member_data = {
**base_data,
'id': uuid.uuid4().hex
}
gift_data = {
**base_data,
'id': uuid.uuid4().hex,
'totalCoin': 450000,
'giftName': '摩天大楼',
'num': 1
}
sc_data = {
**base_data,
'id': str(random.randint(1, 65535)),
'price': 30,
'content': 'The quick brown fox jumps over the lazy dog',
'translation': ''
}
self.send_message(Command.ADD_TEXT, text_data)
text_data[2] = '主播'
text_data[3] = 3
text_data[4] = "I can eat glass, it doesn't hurt me."
text_data[11] = uuid.uuid4().hex
self.send_message(Command.ADD_TEXT, text_data)
self.send_message(Command.ADD_MEMBER, member_data)
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
sc_data['id'] = str(random.randint(1, 65535))
sc_data['price'] = 100
sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗'
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
# self.send_message(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]})
self.send_message(Command.ADD_GIFT, gift_data)
gift_data['id'] = uuid.uuid4().hex
gift_data['totalCoin'] = 1245000
gift_data['giftName'] = '小电视飞船'
self.send_message(Command.ADD_GIFT, gift_data)
@property
def has_joined_room(self):
return self.room_id is not None
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
try:
self.write_message(body)
except tornado.websocket.WebSocketClosedError:
pass

25
api/main.py Normal file
View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
import tornado.web
import api.base
import config
import update
class MainHandler(tornado.web.StaticFileHandler):
"""为了使用Vue Router的history模式把所有请求转发到index.html"""
async def get(self, *args, **kwargs):
await super().get('index.html', *args, **kwargs)
# noinspection PyAbstractClass
class ServerInfoHandler(api.base.ApiHandler):
async def get(self):
cfg = config.get_config()
self.write({
'version': update.VERSION,
'config': {
'enableTranslate': cfg.enable_translate
}
})

Submodule blivedm updated: 87651f044c...15669a2084

45
config.py Normal file
View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
import configparser
import logging
import os
from typing import *
logger = logging.getLogger(__name__)
CONFIG_PATH = os.path.join('data', 'config.ini')
_config: Optional['AppConfig'] = None
def init():
reload()
def reload():
config = AppConfig()
if config.load(CONFIG_PATH):
global _config
_config = config
def get_config():
return _config
class AppConfig:
def __init__(self):
self.database_url = 'sqlite:///data/database.db'
self.enable_translate = True
def load(self, path):
config = configparser.ConfigParser()
config.read(path)
try:
app_section = config['app']
self.database_url = app_section['database_url']
self.enable_translate = app_section.getboolean('enable_translate')
except (KeyError, ValueError):
logger.exception('Failed to load config:')
return False
return True

11
data/config.ini Normal file
View File

@@ -0,0 +1,11 @@
[app]
# See https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
database_url = sqlite:///data/database.db
# Enable auto translate to Japanese
enable_translate = true
# DON'T modify this section
[DEFAULT]
database_url = sqlite:///data/database.db
enable_translate = true

View File

@@ -1,5 +1,13 @@
module.exports = {
presets: [
'@vue/app'
],
plugins: [
[
'component', {
'libraryName': 'element-ui',
'styleLibraryName': 'theme-chalk'
}
]
]
}

15938
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,9 @@
"devDependencies": {
"@vue/cli-plugin-babel": "^3.7.0",
"@vue/cli-plugin-eslint": "^3.7.0",
"@vue/cli-service": "^3.7.0",
"@vue/cli-service": "^4.2.2",
"babel-eslint": "^10.0.1",
"babel-plugin-component": "^1.1.1",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0",
"vue-template-compiler": "^2.5.21"

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,12 +1,12 @@
import axios from 'axios'
import {mergeConfig} from '@/utils'
export const DEFAULT_CONFIG = {
minGiftPrice: 7, // $1
mergeSimilarDanmaku: true,
showDanmaku: true,
showGift: true,
showGiftName: false,
mergeSimilarDanmaku: true,
mergeGift: true,
maxNumber: 60,
blockGiftDanmaku: true,
@@ -17,7 +17,7 @@ export const DEFAULT_CONFIG = {
blockUsers: '',
blockMedalLevel: 0,
css: ''
autoTranslate: false
}
export function setLocalConfig (config) {
@@ -31,27 +31,3 @@ export function getLocalConfig () {
}
return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG)
}
export async function createRemoteConfig (config) {
config = mergeConfig(config, DEFAULT_CONFIG)
return (await axios.post('/config', config)).data
}
export async function setRemoteConfig (id, config) {
config = mergeConfig(config, DEFAULT_CONFIG)
return (await axios.put(`/config/${id}`, config)).data
}
export async function getRemoteConfig (id) {
let config = (await axios.get(`/config/${id}`)).data
return mergeConfig(config, DEFAULT_CONFIG)
}
export default {
DEFAULT_CONFIG,
setLocalConfig,
getLocalConfig,
createRemoteConfig,
setRemoteConfig,
getRemoteConfig
}

View File

@@ -21,7 +21,7 @@
<script>
import ImgShadow from './ImgShadow.vue'
import utils from '@/utils'
import * as utils from '@/utils'
export default {
name: 'LegacyPaidMessage',

View File

@@ -34,7 +34,7 @@
<script>
import ImgShadow from './ImgShadow.vue'
import * as constants from './constants'
import utils from '@/utils'
import * as utils from '@/utils'
export default {
name: 'PaidMessage',

View File

@@ -17,10 +17,12 @@
></author-badge>
</span>
</yt-live-chat-author-chip>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">{{content}}</span>
<el-badge :value="repeated" :max="99" v-show="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
:style="{'--repeated-mark-color': repeatedMarkColor}"
></el-badge>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
{{content}}
<el-badge :value="repeated" :max="99" v-show="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
:style="{'--repeated-mark-color': repeatedMarkColor}"
></el-badge>
</span>
</div>
</yt-live-chat-text-message-renderer>
</template>
@@ -29,7 +31,7 @@
import ImgShadow from './ImgShadow.vue'
import AuthorBadge from './AuthorBadge.vue'
import * as constants from './constants'
import utils from '@/utils'
import * as utils from '@/utils'
// HSL
const REPEATED_MARK_COLOR_START = [210, 100.0, 62.5]
@@ -77,11 +79,11 @@ export default {
</script>
<style>
yt-live-chat-text-message-renderer>#content>.el-badge {
margin-left: 10px;
yt-live-chat-text-message-renderer>#content>#message>.el-badge {
margin-left: 5px;
}
yt-live-chat-text-message-renderer>#content>.el-badge .el-badge__content {
yt-live-chat-text-message-renderer>#content>#message>.el-badge .el-badge__content {
font-size: 12px !important;
line-height: 18px !important;
text-shadow: none !important;

View File

@@ -34,18 +34,19 @@
<paid-message :key="pinnedMessage.id" v-else
class="style-scope yt-live-chat-ticker-renderer"
:price="pinnedMessage.price" :avatarUrl="pinnedMessage.avatarUrl" :authorName="pinnedMessage.authorName"
:time="pinnedMessage.time" :content="pinnedMessage.content"
:time="pinnedMessage.time" :content="showContent"
></paid-message>
</template>
</yt-live-chat-ticker-renderer>
</template>
<script>
import * as config from '@/api/config'
import {formatCurrency} from '@/utils'
import ImgShadow from './ImgShadow.vue'
import LegacyPaidMessage from './LegacyPaidMessage.vue'
import PaidMessage from './PaidMessage.vue'
import * as constants from './constants'
import {formatCurrency} from '@/utils'
export default {
name: 'Ticker',
@@ -55,7 +56,11 @@ export default {
PaidMessage
},
props: {
messages: Array
messages: Array,
showGiftName: {
type: Boolean,
default: config.DEFAULT_CONFIG.showGiftName
}
},
data() {
return {
@@ -65,6 +70,18 @@ export default {
pinnedMessage: null
}
},
computed: {
showContent() {
if (!this.pinnedMessage) {
return ''
}
if (this.pinnedMessage.type === constants.MESSAGE_TYPE_GIFT) {
return constants.getGiftShowContent(this.pinnedMessage, this.showGiftName)
} else {
return constants.getShowContent(this.pinnedMessage)
}
}
},
beforeDestroy() {
window.clearInterval(this.updateTimerId)
},

View File

@@ -18,9 +18,11 @@ export const GUARD_LEVEL_TO_TEXT = [
]
export const MESSAGE_TYPE_TEXT = 0
export const MESSAGE_TYPE_MEMBER = 1
export const MESSAGE_TYPE_SUPER_CHAT = 2
export const MESSAGE_TYPE_DEL = 3
export const MESSAGE_TYPE_GIFT = 1
export const MESSAGE_TYPE_MEMBER = 2
export const MESSAGE_TYPE_SUPER_CHAT = 3
export const MESSAGE_TYPE_DEL = 4
export const MESSAGE_TYPE_UPDATE = 5
// 美元 -> 人民币 汇率
const EXCHANGE_RATE = 7
@@ -119,3 +121,17 @@ export function getPriceConfig (price) {
}
return PRICE_CONFIGS[PRICE_CONFIGS.length - 1]
}
export function getShowContent(message) {
if (message.translation) {
return `${message.content}${message.translation}`
}
return message.content
}
export function getGiftShowContent(message, showGiftName) {
if (!showGiftName) {
return ''
}
return `Sent ${message.giftName}x${message.num}`
}

View File

@@ -1,17 +1,28 @@
<template>
<yt-live-chat-renderer class="style-scope yt-live-chat-app" style="--scrollbar-width:11px;" hide-timestamps>
<ticker class="style-scope yt-live-chat-renderer" :messages="paidMessages" :hidden="paidMessages.length === 0"></ticker>
<yt-live-chat-renderer class="style-scope yt-live-chat-app" style="--scrollbar-width:11px;" hide-timestamps
@mousemove="refreshCantScrollStartTime"
>
<ticker class="style-scope yt-live-chat-renderer" :messages="paidMessages" :showGiftName="showGiftName"
:hidden="paidMessages.length === 0"
></ticker>
<yt-live-chat-item-list-renderer class="style-scope yt-live-chat-renderer" allow-scroll>
<div id="item-scroller" ref="scroller" class="style-scope yt-live-chat-item-list-renderer animated" @scroll="onScroll">
<div ref="scroller" id="item-scroller" class="style-scope yt-live-chat-item-list-renderer animated" @scroll="onScroll">
<div ref="itemOffset" id="item-offset" class="style-scope yt-live-chat-item-list-renderer" style="height: 0px;">
<div ref="items" id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden; transform: translateY(0px);">
<div ref="items" id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden"
:style="{transform: `translateY(${Math.floor(scrollPixelsRemaining)}px)`}"
>
<template v-for="message in messages">
<text-message :key="message.id" v-if="message.type === MESSAGE_TYPE_TEXT"
class="style-scope yt-live-chat-item-list-renderer"
:avatarUrl="message.avatarUrl" :time="message.time" :authorName="message.authorName"
:authorType="message.authorType" :content="message.content" :privilegeType="message.privilegeType"
:authorType="message.authorType" :content="getShowContent(message)" :privilegeType="message.privilegeType"
:repeated="message.repeated"
></text-message>
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_GIFT"
class="style-scope yt-live-chat-item-list-renderer"
:price="message.price" :avatarUrl="message.avatarUrl" :authorName="message.authorName"
:time="message.time" :content="getGiftShowContent(message)"
></paid-message>
<legacy-paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-item-list-renderer"
:avatarUrl="message.avatarUrl" :title="message.title" :content="message.content"
@@ -20,7 +31,7 @@
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_SUPER_CHAT"
class="style-scope yt-live-chat-item-list-renderer"
:price="message.price" :avatarUrl="message.avatarUrl" :authorName="message.authorName"
:time="message.time" :content="message.content"
:time="message.time" :content="getShowContent(message)"
></paid-message>
</template>
</div>
@@ -31,7 +42,7 @@
</template>
<script>
import config from '@/api/config'
import * as config from '@/api/config'
import Ticker from './Ticker.vue'
import TextMessage from './TextMessage.vue'
import LegacyPaidMessage from './LegacyPaidMessage.vue'
@@ -54,6 +65,10 @@ export default {
maxNumber: {
type: Number,
default: config.DEFAULT_CONFIG.maxNumber
},
showGiftName: {
type: Boolean,
default: config.DEFAULT_CONFIG.showGiftName
}
},
data() {
@@ -61,6 +76,7 @@ export default {
document.head.appendChild(styleElement)
return {
MESSAGE_TYPE_TEXT: constants.MESSAGE_TYPE_TEXT,
MESSAGE_TYPE_GIFT: constants.MESSAGE_TYPE_GIFT,
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
MESSAGE_TYPE_SUPER_CHAT: constants.MESSAGE_TYPE_SUPER_CHAT,
@@ -84,7 +100,8 @@ export default {
smoothScrollRafHandle: null, // 平滑滚动requestAnimationFrame句柄
lastSmoothScrollUpdate: null, // 平滑滚动上一帧时间
atBottom: true // 滚动到底部,用来判断能否自动滚动
atBottom: true, // 滚动到底部,用来判断能否自动滚动
cantScrollStartTime: null // 开始不能自动滚动的时间,用来防止卡住
}
},
computed: {
@@ -92,6 +109,14 @@ export default {
return this.atBottom/* || this.allowScroll*/
}
},
watch: {
css(val) {
this.styleElement.innerText = val
},
canScrollToBottom(val) {
this.cantScrollStartTime = val ? null : new Date()
}
},
mounted() {
this.styleElement.innerText = this.css
this.scrollToBottom()
@@ -102,41 +127,81 @@ export default {
window.clearTimeout(this.emitSmoothedMessageTimerId)
this.emitSmoothedMessageTimerId = null
}
},
watch: {
css(val) {
this.styleElement.innerText = val
}
this.clearMessages()
},
methods: {
getGiftShowContent(message) {
return constants.getGiftShowContent(message, this.showGiftName)
},
getShowContent: constants.getShowContent,
addMessage(message) {
this.addMessages([message])
},
addMessages(messages) {
this.enqueueMessages(messages)
},
mergeSimilar(content) {
let remainNum = 5
for (let arr of [this.messagesBuffer, this.messages]) {
for (let i = arr.length - 1; i >= 0 && --remainNum > 0; i--) {
let message = arr[i]
let longer, shorter
if (message.content.length > content.length) {
longer = message.content
shorter = content
} else {
longer = content
shorter = message.content
}
if (longer.indexOf(shorter) !== -1 // 长的包含短的
&& longer.length - shorter.length < shorter.length // 长度差较小
) {
message.repeated++
return true
mergeSimilarText(content) {
content = content.trim().toLowerCase()
let res = false
this.forEachRecentMessage(5, message => {
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
return true
}
let messageContent = message.content.trim().toLowerCase()
let longer, shorter
if (messageContent.length > content.length) {
longer = messageContent
shorter = content
} else {
longer = content
shorter = messageContent
}
if (longer.indexOf(shorter) !== -1 // 长的包含短的
&& longer.length - shorter.length < shorter.length // 长度差较小
) {
// 其实有小概率导致弹幕卡住
message.repeated++
res = true
return false
}
return true
})
return res
},
mergeSimilarGift(authorName, price, giftName, num) {
let res = false
this.forEachRecentMessage(5, message => {
if (message.type === constants.MESSAGE_TYPE_GIFT
&& message.authorName === authorName
&& message.giftName === giftName
) {
message.price += price
message.num += num
res = true
return false
}
return true
})
return res
},
forEachRecentMessage(num, callback) {
// 从新到老遍历num条消息
for (let i = this.smoothedMessageQueue.length - 1; i >= 0 && num > 0; i--) {
let messageGroup = this.smoothedMessageQueue[i]
for (let j = messageGroup.length - 1; j >= 0 && num-- > 0; j--) {
if (!callback(messageGroup[j])) {
return
}
}
}
for (let arr of [this.messagesBuffer, this.messages]) {
for (let i = arr.length - 1; i >= 0 && num-- > 0; i--) {
if (!callback(arr[i])) {
return
}
}
}
return false
},
delMessage(id) {
this.delMessages([id])
@@ -144,20 +209,41 @@ export default {
delMessages(ids) {
this.enqueueMessages(ids.map(id => {
return {
id: id,
type: constants.MESSAGE_TYPE_DEL
type: constants.MESSAGE_TYPE_DEL,
id
}
}))
},
clearMessages() {
this.messages = []
this.paidMessages = []
this.smoothedMessageQueue = []
this.messagesBuffer = []
this.isSmoothed = true
this.lastSmoothChatMessageAddMs = null
this.chatRateMs = 1000
this.lastSmoothScrollUpdate = null
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
this.smoothScrollRafHandle = null
this.preinsertHeight = 0
this.maybeResizeScrollContainer()
if (!this.atBottom) {
this.scrollToBottom()
}
},
updateMessage(id, newValuesObj) {
this.enqueueMessages([{
type: constants.MESSAGE_TYPE_UPDATE,
id,
newValuesObj
}])
},
enqueueMessages(messages) {
if (this.lastEnqueueTime) {
let interval = new Date() - this.lastEnqueueTime
if (interval > 0) {
// 理论上B站发包间隔1S如果不过滤间隔太短的会导致消息平滑失效
if (interval > 100) {
this.enqueueIntervals.push(interval)
if (this.enqueueIntervals.length > 5) {
this.enqueueIntervals.splice(0, this.enqueueIntervals.length - 5)
@@ -171,7 +257,7 @@ export default {
let messageGroup = []
for (let message of messages) {
messageGroup.push(message)
if (message.type !== constants.MESSAGE_TYPE_DEL) {
if (message.type !== constants.MESSAGE_TYPE_DEL && message.type !== constants.MESSAGE_TYPE_UPDATE) {
this.smoothedMessageQueue.push(messageGroup)
messageGroup = []
}
@@ -242,12 +328,16 @@ export default {
for (let message of messageGroup) {
switch (message.type) {
case constants.MESSAGE_TYPE_TEXT:
case constants.MESSAGE_TYPE_GIFT:
case constants.MESSAGE_TYPE_MEMBER:
case constants.MESSAGE_TYPE_SUPER_CHAT:
this.handleAddMessage(message)
break
case constants.MESSAGE_TYPE_DEL:
this.handleDelMessage(message.id)
this.handleDelMessage(message)
break
case constants.MESSAGE_TYPE_UPDATE:
this.handleUpdateMessage(message)
break
}
}
@@ -263,26 +353,49 @@ export default {
}
this.messagesBuffer.push(message)
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
this.paidMessages.push(message)
this.paidMessages.unshift(message)
}
},
handleDelMessage(message) {
let id = message.id
handleDelMessage({id}) {
for (let arr of [this.messages, this.paidMessages, this.messagesBuffer]) {
for (let i = 0; i < arr.length; i++) {
if (arr[i].id === id) {
arr.splice(i, 1)
this.resetSmoothScroll()
break
}
}
}
},
handleUpdateMessage({id, newValuesObj}) {
// 遍历滚动的消息
this.forEachRecentMessage(999999999, message => {
if (message.id !== id) {
return true
}
for (let name in newValuesObj) {
message[name] = newValuesObj[name]
}
return false
})
// 遍历固定的消息
for (let message of this.paidMessages) {
if (message.id !== id) {
continue
}
for (let name in newValuesObj) {
message[name] = newValuesObj[name]
}
break
}
this.resetSmoothScroll()
},
async flushMessagesBuffer() {
if (this.messagesBuffer.length <= 0) {
return
}
if (!this.canScrollToBottom) {
if (!this.canScrollToBottomOrTimedOut()) {
if (this.messagesBuffer.length > this.maxNumber) {
// 未显示消息数 > 最大可显示数,丢弃
this.messagesBuffer.splice(0, this.messagesBuffer.length - this.maxNumber)
@@ -308,14 +421,13 @@ export default {
showNewMessages() {
let hasScrollBar = this.$refs.items.clientHeight > this.$refs.scroller.clientHeight
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
if (!this.canScrollToBottom || !hasScrollBar) {
if (!this.canScrollToBottomOrTimedOut() || !hasScrollBar) {
return
}
// 计算剩余像素
this.scrollPixelsRemaining += this.$refs.items.clientHeight - this.preinsertHeight
this.scrollToBottom()
this.$refs.items.style.transform = `translateY(${Math.floor(this.scrollPixelsRemaining)}px)`
// 计算是否平滑滚动、剩余时间
if (!this.lastSmoothChatMessageAddMs) {
@@ -354,7 +466,6 @@ export default {
|| this.scrollTimeRemainingMs <= 0 // 时间已结束
) {
this.resetSmoothScroll()
this.$refs.items.style.transform = 'translateY(0px)'
return
}
@@ -369,7 +480,6 @@ export default {
}
this.lastSmoothScrollUpdate = time
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
this.$refs.items.style.transform = `translateY(${Math.floor(this.scrollPixelsRemaining)}px)`
},
resetSmoothScroll() {
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
@@ -385,18 +495,32 @@ export default {
this.maybeScrollToBottom()
},
maybeScrollToBottom() {
if (this.canScrollToBottom) {
if (this.canScrollToBottomOrTimedOut()) {
this.scrollToBottom()
}
},
scrollToBottom() {
this.$refs.scroller.scrollTop = this.$refs.scroller.scrollHeight
this.$refs.scroller.scrollTop = Math.pow(2, 24)
this.atBottom = true
},
onScroll() {
this.refreshCantScrollStartTime()
let scroller = this.$refs.scroller
this.atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLED_TO_BOTTOM_EPSILON
this.flushMessagesBuffer()
},
canScrollToBottomOrTimedOut() {
if (this.canScrollToBottom) {
return true
}
// 防止在OBS中卡住超过一定时间也可以自动滚动
return new Date() - this.cantScrollStartTime >= 10 * 1000
},
refreshCantScrollStartTime() {
// 有鼠标事件时刷新,防止用户看弹幕时自动滚动
if (this.cantScrollStartTime) {
this.cantScrollStartTime = new Date()
}
}
}
}

View File

@@ -2,6 +2,7 @@ export default {
sidebar: {
home: 'Home',
stylegen: 'Style generator',
help: 'Help',
projectAddress: 'Project address',
giftRecord: 'Super Chat record'
},
@@ -13,7 +14,9 @@ export default {
roomId: 'Room ID',
showDanmaku: 'Show messages',
showGift: 'Show Super Chats',
showGiftName: 'Show gift name',
mergeSimilarDanmaku: 'Merge similar messages',
mergeGift: 'Merge gifts',
minGiftPrice: 'Min price of Super Chats to show (CNY)',
maxNumber: 'Max number of messages',
@@ -27,17 +30,15 @@ export default {
blockUsers: 'Block users',
blockMedalLevel: 'Block medal level lower than',
style: 'Style',
advanced: 'Advanced',
autoTranslate: 'Auto translate messages to Japanese',
roomUrl: 'Room URL',
copy: 'Copy',
saveConfig: 'Save config',
enterRoom: 'Enter room',
exportConfig: 'Export config',
importConfig: 'Import config',
failedToSave: 'Failed to save: ',
successfullySaved: 'Successfully saved',
failedToParseConfig: 'Failed to parse config: '
},
stylegen: {
@@ -106,5 +107,13 @@ export default {
result: 'Result',
copy: 'Copy',
resetConfig: 'Reset config'
},
help: {
help: 'Help',
p1: '1. Copy the room ID from the Bilibili live room webpage',
p2: '2. Enter the room ID into the room ID on the home page. Copy the room URL',
p3: '3. Generate styles with the style generator. Copy the CSS',
p4: '4. Add browser source in OBS',
p5: '5. Enter the previously copied room URL at URL, and enter the previously copied CSS at custom CSS'
}
}

View File

@@ -1,7 +1,8 @@
export default {
export default {
sidebar: {
home: 'トップページ',
stylegen: 'スタイルジェネレータ',
help: 'ヘルプ',
projectAddress: 'プロジェクトアドレス',
giftRecord: 'スーパーチャット記録'
},
@@ -13,7 +14,9 @@
roomId: 'ルームID',
showDanmaku: 'コメントを表示する',
showGift: 'スーパーチャットと新メンバーを表示する',
showGiftName: 'ギフト名を表示する',
mergeSimilarDanmaku: '同じコメントを合併する',
mergeGift: 'ギフトを合併する',
minGiftPrice: '最低表示スーパーチャット価格CNY',
maxNumber: '最大コメント数',
@@ -27,17 +30,15 @@
blockUsers: 'ブロックユーザー',
blockMedalLevel: 'ブロック勲章等級がx未満',
style: 'スタイル',
advanced: 'アドバンスド',
autoTranslate: '自動翻訳コメントから日本語へ',
roomUrl: 'ルームのURL',
copy: 'コピー',
saveConfig: 'コンフィグを保存する',
enterRoom: 'ルームに入る',
exportConfig: 'コンフィグの導出',
importConfig: 'コンフィグの導入',
failedToSave: '保存に失敗しました:',
successfullySaved: '保存に成功しました',
failedToParseConfig: 'コンフィグ解析に失敗しました'
},
stylegen: {
@@ -106,5 +107,13 @@
result: '結果',
copy: 'コピー',
resetConfig: 'デフォルトに戻す'
},
help: {
help: 'ヘルプ',
p1: '1. ビリビリの生放送ウェブから生放送ルームIDをこぴーする',
p2: '2. ホームページでコピーしたIDを入力し、ルームのURLをこぴーする',
p3: '3. スタイルジェネレータでお好みのコメント様子を選び、出力したCSSをコピーする',
p4: '4. OBSでブラウザを新規作成する',
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'
}
}

View File

@@ -2,6 +2,7 @@ export default {
sidebar: {
home: '首页',
stylegen: '样式生成器',
help: '帮助',
projectAddress: '项目地址',
giftRecord: '打赏记录'
},
@@ -13,7 +14,9 @@ export default {
roomId: '房间ID',
showDanmaku: '显示弹幕',
showGift: '显示打赏和新舰长',
showGiftName: '显示礼物名',
mergeSimilarDanmaku: '合并相似弹幕',
mergeGift: '合并礼物',
minGiftPrice: '最低显示打赏价格(元)',
maxNumber: '最大弹幕数',
@@ -27,17 +30,15 @@ export default {
blockUsers: '屏蔽用户',
blockMedalLevel: '屏蔽当前直播间勋章等级低于',
style: '样式',
advanced: '高级',
autoTranslate: '自动翻译弹幕到日语',
roomUrl: '房间URL',
copy: '复制',
saveConfig: '保存配置',
enterRoom: '进入房间',
exportConfig: '导出配置',
importConfig: '导入配置',
failedToSave: '保存失败:',
successfullySaved: '保存成功',
failedToParseConfig: '配置解析失败:'
},
stylegen: {
@@ -90,8 +91,8 @@ export default {
scContentLineLineHeight: 'Super Chat内容行高0为默认',
scContentLineColor: 'Super Chat内容颜色',
showNewMemberBg: '显示新舰长背景',
showScTicker: '显示Super Chat贴纸',
showOtherThings: '显示Super Chat贴纸之外的内容',
showScTicker: '显示Super Chat固定栏',
showOtherThings: '显示Super Chat固定栏之外的内容',
animation: '动画',
animateIn: '进入动画',
@@ -106,5 +107,13 @@ export default {
result: '结果',
copy: '复制',
resetConfig: '恢复默认设置'
},
help: {
help: '帮助',
p1: '1. 从B站直播间网页复制房间ID',
p2: '2. 把房间ID输入到首页的房间ID复制房间URL',
p3: '3. 使用样式生成器生成样式复制CSS',
p4: '4. 在OBS中添加浏览器源',
p5: '5. URL处输入之前复制的房间URL自定义CSS处输入之前复制的CSS'
}
}

View File

@@ -13,6 +13,9 @@
<el-menu-item :index="$router.resolve({name: 'stylegen'}).href">
<i class="el-icon-brush"></i>{{$t('sidebar.stylegen')}}
</el-menu-item>
<el-menu-item :index="$router.resolve({name: 'help'}).href">
<i class="el-icon-question"></i>{{$t('sidebar.help')}}
</el-menu-item>
<a href="https://github.com/xfgryujk/blivechat" target="_blank">
<el-menu-item>
<i class="el-icon-share"></i>{{$t('sidebar.projectAddress')}}

View File

@@ -9,7 +9,7 @@
</router-link>
</div>
<div class="version">
v1.2.3
v1.4.1
</div>
<sidebar></sidebar>
</el-aside>

View File

@@ -1,14 +1,17 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import VueI18n from 'vue-i18n'
import {
Aside, Autocomplete, Badge, Button, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Main, Menu, MenuItem, Message, Row, Scrollbar, Slider, Submenu, Switch, TabPane, Tabs, Tooltip
} from 'element-ui'
import axios from 'axios'
import App from './App.vue'
import Layout from './layout'
import Home from './views/Home.vue'
import StyleGenerator from './views/StyleGenerator'
import Help from './views/Help'
import Room from './views/Room.vue'
import NotFound from './views/NotFound.vue'
@@ -22,8 +25,32 @@ if (process.env.NODE_ENV === 'development') {
}
Vue.use(VueRouter)
Vue.use(ElementUI)
Vue.use(VueI18n)
// 初始化element
Vue.use(Aside)
Vue.use(Autocomplete)
Vue.use(Badge)
Vue.use(Button)
Vue.use(Col)
Vue.use(ColorPicker)
Vue.use(Container)
Vue.use(Divider)
Vue.use(Form)
Vue.use(FormItem)
Vue.use(Image)
Vue.use(Input)
Vue.use(Main)
Vue.use(Menu)
Vue.use(MenuItem)
Vue.use(Row)
Vue.use(Scrollbar)
Vue.use(Slider)
Vue.use(Submenu)
Vue.use(Switch)
Vue.use(TabPane)
Vue.use(Tabs)
Vue.use(Tooltip)
Vue.prototype.$message = Message
Vue.config.ignoredElements = [
/^yt-/
@@ -37,7 +64,8 @@ const router = new VueRouter({
component: Layout,
children: [
{path: '', component: Home},
{path: 'stylegen', name: 'stylegen', component: StyleGenerator}
{path: 'stylegen', name: 'stylegen', component: StyleGenerator},
{path: 'help', name: 'help', component: Help}
]
},
{path: '/room/:roomId', name: 'room', component: Room},

View File

@@ -6,6 +6,21 @@ export function mergeConfig (config, defaultConfig) {
return res
}
export function toBool (val) {
if (typeof val === 'string') {
return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
}
return !!val
}
export function toInt (val, _default) {
let res = parseInt(val)
if (isNaN(res)) {
res = _default
}
return res
}
export function formatCurrency (price) {
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: price < 100 ? 2 : 0
@@ -17,9 +32,3 @@ export function getTimeTextMinSec (date) {
let sec = ('00' + date.getSeconds()).slice(-2)
return `${min}:${sec}`
}
export default {
mergeConfig,
formatCurrency,
getTimeTextMinSec
}

View File

@@ -0,0 +1,21 @@
<template>
<div>
<h1>{{$t('help.help')}}</h1>
<p>{{$t('help.p1')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p>{{$t('help.p2')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-2.png"></el-image></p>
<p>{{$t('help.p3')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-3.png"></el-image></p>
<p>{{$t('help.p4')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-4.png"></el-image></p>
<p>{{$t('help.p5')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-5.png"></el-image></p>
</div>
</template>
<script>
export default {
name: 'Help'
}
</script>

View File

@@ -16,9 +16,15 @@
<el-form-item :label="$t('home.showGift')">
<el-switch v-model="form.showGift"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.showGiftName')">
<el-switch v-model="form.showGiftName"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.mergeSimilarDanmaku')">
<el-switch v-model="form.mergeSimilarDanmaku"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.mergeGift')">
<el-switch v-model="form.mergeGift"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.minGiftPrice')">
<el-input v-model.number="form.minGiftPrice" type="number" min="0"></el-input>
</el-form-item>
@@ -51,20 +57,19 @@
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('home.style')">
<el-form-item label="CSS">
<el-input v-model="form.css" type="textarea" :rows="20"></el-input>
<el-tab-pane :label="$t('home.advanced')">
<el-form-item :label="$t('home.autoTranslate')">
<el-switch v-model="form.autoTranslate" :disabled="!serverConfig.enableTranslate"></el-switch>
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-divider></el-divider>
<el-form-item :label="$t('home.roomUrl')" v-show="roomUrl">
<el-input ref="roomUrlInput" readonly :value="roomUrl" style="width: calc(100% - 6em); margin-right: 1em;"></el-input>
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="roomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" @click="copyUrl">{{$t('home.copy')}}</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig">{{$t('home.saveConfig')}}</el-button>
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
<el-button type="primary" @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
<el-button type="primary" @click="importConfig">{{$t('home.importConfig')}}</el-button>
@@ -73,51 +78,53 @@
</template>
<script>
import _ from 'lodash'
import axios from 'axios'
import download from 'downloadjs'
import {mergeConfig} from '@/utils'
import config from '@/api/config'
import * as config from '@/api/config'
export default {
name: 'Home',
data() {
return {
serverConfig: {
enableTranslate: true
},
form: {
roomId: parseInt(window.localStorage.roomId || '1'),
...config.getLocalConfig()
},
roomUrl: ''
}
}
},
computed: {
roomUrl() {
if (this.form.roomId === '') {
return ''
}
let query = {...this.form}
delete query.roomId
let resolved = this.$router.resolve({name: 'room', params: {roomId: this.form.roomId}, query})
return `${window.location.protocol}//${window.location.host}${resolved.href}`
}
},
watch: {
roomUrl: _.debounce(function() {
window.localStorage.roomId = this.form.roomId
config.setLocalConfig(this.form)
}, 500)
},
mounted() {
this.updateServerConfig()
},
methods: {
saveConfig() {
this.$refs.form.validate(async valid => {
if (!valid) {
return
}
window.localStorage.roomId = this.form.roomId
config.setLocalConfig(this.form)
try {
if (window.localStorage.configId) {
try {
await config.setRemoteConfig(window.localStorage.configId, this.form)
} catch (e) { // 404
window.localStorage.configId = (await config.createRemoteConfig(this.form)).id
}
} else {
window.localStorage.configId = (await config.createRemoteConfig(this.form)).id
}
} catch (e) {
this.$message.error(this.$t('home.failedToSave') + e)
return
}
this.$message({message: this.$t('home.successfullySaved'), type: 'success'})
let resolved = this.$router.resolve({name: 'room', params: {roomId: this.form.roomId},
query: {config_id: window.localStorage.configId}})
this.roomUrl = `http://${window.location.host}${resolved.href}`
})
async updateServerConfig() {
try {
this.serverConfig = (await axios.get(`/server_info`)).data.config
} catch (e) {
this.$message.error('Failed to fetch server information: ' + e)
}
},
enterRoom() {
window.open(this.roomUrl, `room ${this.form.roomId}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')

View File

@@ -1,9 +1,10 @@
<template>
<chat-renderer ref="renderer" :css="config.css" :maxNumber="config.maxNumber"></chat-renderer>
<chat-renderer ref="renderer" :maxNumber="config.maxNumber" :showGiftName="config.showGiftName"></chat-renderer>
</template>
<script>
import config from '@/api/config'
import {mergeConfig, toBool, toInt} from '@/utils'
import * as config from '@/api/config'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
@@ -14,6 +15,7 @@ const COMMAND_ADD_GIFT = 3
const COMMAND_ADD_MEMBER = 4
const COMMAND_ADD_SUPER_CHAT = 5
const COMMAND_DEL_SUPER_CHAT = 6
const COMMAND_UPDATE_TRANSLATION = 7
export default {
name: 'Room',
@@ -27,9 +29,7 @@ export default {
websocket: null,
retryCount: 0,
isDestroying: false,
heartbeatTimerId: null,
nextId: 0,
heartbeatTimerId: null
}
},
computed: {
@@ -41,22 +41,44 @@ export default {
}
},
created() {
this.updateConfig()
this.wsConnect()
if (this.$route.query.config_id) {
this.updateConfig(this.$route.query.config_id)
}
// 提示用户已加载
this.$message({
message: 'Loaded',
duration: '500'
})
},
beforeDestroy() {
this.isDestroying = true
this.websocket.close()
},
methods: {
async updateConfig(configId) {
try {
this.config = await config.getRemoteConfig(configId)
} catch (e) {
this.$message.error('获取配置失败:' + e)
updateConfig() {
let cfg = {}
// 留空的使用默认值
for (let i in this.$route.query) {
if (this.$route.query[i] !== '') {
cfg[i] = this.$route.query[i]
}
}
cfg = mergeConfig(cfg, config.DEFAULT_CONFIG)
cfg.minGiftPrice = toInt(cfg.minGiftPrice, config.DEFAULT_CONFIG.minGiftPrice)
cfg.showDanmaku = toBool(cfg.showDanmaku)
cfg.showGift = toBool(cfg.showGift)
cfg.showGiftName = toBool(cfg.showGiftName)
cfg.mergeSimilarDanmaku = toBool(cfg.mergeSimilarDanmaku)
cfg.mergeGift = toBool(cfg.mergeGift)
cfg.maxNumber = toInt(cfg.maxNumber, config.DEFAULT_CONFIG.maxNumber)
cfg.blockGiftDanmaku = toBool(cfg.blockGiftDanmaku)
cfg.blockLevel = toInt(cfg.blockLevel, config.DEFAULT_CONFIG.blockLevel)
cfg.blockNewbie = toBool(cfg.blockNewbie)
cfg.blockNotMobileVerified = toBool(cfg.blockNotMobileVerified)
cfg.blockMedalLevel = toInt(cfg.blockMedalLevel, config.DEFAULT_CONFIG.blockMedalLevel)
cfg.autoTranslate = toBool(cfg.autoTranslate)
this.config = cfg
},
wsConnect() {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
@@ -79,7 +101,10 @@ export default {
this.websocket.send(JSON.stringify({
cmd: COMMAND_JOIN_ROOM,
data: {
roomId: parseInt(this.$route.params.roomId)
roomId: parseInt(this.$route.params.roomId),
config: {
autoTranslate: this.config.autoTranslate
}
}
}))
},
@@ -97,22 +122,37 @@ export default {
onWsMessage(event) {
let {cmd, data} = JSON.parse(event.data)
let message = null
let time = data.timestamp ? new Date(data.timestamp * 1000) : new Date()
switch (cmd) {
case COMMAND_ADD_TEXT:
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilar(data.content)) {
data = {
avatarUrl: data[0],
timestamp: data[1],
authorName: data[2],
authorType: data[3],
content: data[4],
privilegeType: data[5],
isGiftDanmaku: !!data[6],
authorLevel: data[7],
isNewbie: !!data[8],
isMobileVerified: !!data[9],
medalLevel: data[10],
id: data[11],
translation: data[12]
}
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilarText(data.content)) {
break
}
message = {
id: `text_${this.nextId++}`,
id: data.id,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: data.avatarUrl,
time: time,
time: new Date(data.timestamp * 1000),
authorName: data.authorName,
authorType: data.authorType,
content: data.content,
privilegeType: data.privilegeType,
repeated: 1
repeated: 1,
translation: data.translation
}
break
case COMMAND_ADD_GIFT: {
@@ -120,57 +160,70 @@ export default {
break
}
let price = data.totalCoin / 1000
if (this.mergeSimilarGift(data.authorName, price, data.giftName, data.num)) {
break
}
if (price < this.config.minGiftPrice) { // 丢人
break
}
message = {
id: `gift_${this.nextId++}`,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
id: data.id,
type: constants.MESSAGE_TYPE_GIFT,
avatarUrl: data.avatarUrl,
time: new Date(data.timestamp * 1000),
authorName: data.authorName,
price: price,
time: time,
content: '' // 有了SC礼物不需要内容了
giftName: data.giftName,
num: data.num
}
break
}
case COMMAND_ADD_MEMBER:
if (!this.config.showGift || !this.filterSuperChatMessage(data)) {
if (!this.config.showGift || !this.filterNewMemberMessage(data)) {
break
}
message = {
id: `member_${this.nextId++}`,
id: data.id,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: data.avatarUrl,
time: time,
time: new Date(data.timestamp * 1000),
authorName: data.authorName,
title: 'NEW MEMBER!',
content: `Welcome ${data.authorName}!`
}
break
case COMMAND_ADD_SUPER_CHAT:
if (!this.config.showGift) {
if (!this.config.showGift || !this.filterSuperChatMessage(data)) {
break
}
if (data.price < this.config.minGiftPrice) { // 丢人
break
}
message = {
id: `sc_${data.id}`,
id: data.id,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: data.avatarUrl,
authorName: data.authorName,
price: data.price,
time: time,
content: data.content
time: new Date(data.timestamp * 1000),
content: data.content.trim()
}
break
case COMMAND_DEL_SUPER_CHAT:
for (let id of data.ids) {
id = `sc_${id}`
this.$refs.renderer.delMessage(id)
}
break
case COMMAND_UPDATE_TRANSLATION:
if (!this.config.autoTranslate) {
break
}
data = {
id: data[0],
translation: data[1]
}
this.$refs.renderer.updateMessage(data.id, {translation: data.translation})
break
}
if (message) {
this.$refs.renderer.addMessage(message)
@@ -196,6 +249,9 @@ export default {
return false
}
}
return this.filterNewMemberMessage(data)
},
filterNewMemberMessage(data) {
for (let user of this.blockUsers) {
if (data.authorName === user) {
return false
@@ -203,11 +259,17 @@ export default {
}
return true
},
mergeSimilar(content) {
mergeSimilarText(content) {
if (!this.config.mergeSimilarDanmaku) {
return false
}
return this.$refs.renderer.mergeSimilar(content)
return this.$refs.renderer.mergeSimilarText(content)
},
mergeSimilarGift(authorName, price, giftName, num) {
if (!this.config.mergeGift) {
return false
}
return this.$refs.renderer.mergeSimilarGift(authorName, price, giftName, num)
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -200,8 +200,8 @@
<script>
import _ from 'lodash'
import stylegen from './stylegen'
import fonts from './fonts'
import * as stylegen from './stylegen'
import * as fonts from './fonts'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
@@ -216,7 +216,8 @@ let textMessageTemplate = {
authorType: constants.AUTHRO_TYPE_NORMAL,
content: '',
privilegeType: 0,
repeated: 1
repeated: 1,
translation: ''
}
let legacyPaidMessageTemplate = {
id: 0,
@@ -236,20 +237,21 @@ let paidMessageTemplate = {
authorName: '',
price: 0,
time: time,
content: ''
content: '',
translation: ''
}
let nextId = 0
const EXAMPLE_MESSAGES = [
{
...textMessageTemplate,
id: nextId++,
id: (nextId++).toString(),
authorName: 'mob路人',
content: '8888888888',
repeated: 12
},
{
...textMessageTemplate,
id: nextId++,
id: (nextId++).toString(),
authorName: 'member舰长',
authorType: constants.AUTHRO_TYPE_MEMBER,
content: '草',
@@ -258,37 +260,37 @@ const EXAMPLE_MESSAGES = [
},
{
...textMessageTemplate,
id: nextId++,
id: (nextId++).toString(),
authorName: 'admin房管',
authorType: constants.AUTHRO_TYPE_ADMIN,
content: 'kksk'
},
{
...legacyPaidMessageTemplate,
id: nextId++,
authorName: '吾乐KANA',
content: 'Welcome 吾乐KANA!'
id: (nextId++).toString(),
authorName: '进击的冰糖',
content: 'Welcome 进击的冰糖!'
},
{
...paidMessageTemplate,
id: nextId++,
id: (nextId++).toString(),
authorName: '无火的残渣',
price: 66600,
content: 'Sent 小电视飞船x100'
},
{
...textMessageTemplate,
id: nextId++,
id: (nextId++).toString(),
authorName: 'streamer主播',
authorType: constants.AUTHRO_TYPE_OWNER,
content: '感谢石油佬送的小电视'
content: '老板大气,老板身体健康'
},
{
...paidMessageTemplate,
id: nextId++,
id: (nextId++).toString(),
authorName: '夏色祭保護協会会長',
price: 28,
content: 'Sent 礼花x1'
price: 30,
content: '言いたいことがあるんだよ!'
}
]

View File

@@ -1,5 +1,5 @@
import {mergeConfig} from '@/utils'
import fonts from './fonts'
import * as fonts from './fonts'
export const DEFAULT_CONFIG = {
showOutlines: true,
@@ -404,10 +404,3 @@ yt-live-chat-legacy-paid-message-renderer {
animation-fill-mode: both;
}`
}
export default {
DEFAULT_CONFIG,
setLocalConfig,
getLocalConfig,
getStyle
}

65
main.py
View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
import argparse
import asyncio
import logging
import os
import webbrowser
@@ -9,55 +8,77 @@ import webbrowser
import tornado.ioloop
import tornado.web
import api.chat
import api.main
import config
import models.avatar
import models.database
import models.translate
import update
import views.chat
import views.config
import views.main
logger = logging.getLogger(__name__)
WEB_ROOT = os.path.join(os.path.dirname(__file__), 'frontend', 'dist')
routes = [
(r'/server_info', api.main.ServerInfoHandler),
(r'/chat', api.chat.ChatHandler),
(r'/((css|fonts|img|js|static)/.*)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/(favicon\.ico)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/.*', api.main.MainHandler, {'path': WEB_ROOT})
]
def main():
args = parse_args()
init_logging(args.debug)
config.init()
models.database.init(args.debug)
models.avatar.init()
models.translate.init()
api.chat.init()
update.check_update()
run_server(args.host, args.port, args.debug)
def parse_args():
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播聊天层')
parser.add_argument('--host', help='服务器host默认为127.0.0.1', default='127.0.0.1')
parser.add_argument('--port', help='服务器端口默认为12450', type=int, default=12450)
parser.add_argument('--debug', help='调试模式', action='store_true')
args = parser.parse_args()
return parser.parse_args()
def init_logging(debug):
logging.basicConfig(
format='{asctime} {levelname} [{name}]: {message}',
datefmt='%Y-%m-%d %H:%M:%S',
style='{',
level=logging.INFO if not args.debug else logging.DEBUG
level=logging.INFO if not debug else logging.DEBUG
)
asyncio.ensure_future(update.check_update())
def run_server(host, port, debug):
app = tornado.web.Application(
[
(r'/chat', views.chat.ChatHandler),
(r'/config', views.config.ConfigsHandler),
(r'/config/(.+)', views.config.ConfigHandler),
(r'/((css|fonts|img|js|static)/.*)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/(favicon\.ico)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/.*', views.main.MainHandler, {'path': WEB_ROOT})
],
websocket_ping_interval=30,
debug=args.debug,
routes,
websocket_ping_interval=10,
debug=debug,
autoreload=False
)
try:
app.listen(args.port, args.host)
app.listen(port, host)
except OSError:
logger.warning('Address is used %s:%d', args.host, args.port)
logger.warning('Address is used %s:%d', host, port)
return
finally:
url = 'http://localhost' if args.port == 80 else f'http://localhost:{args.port}'
url = 'http://localhost/' if port == 80 else f'http://localhost:{port}/'
# 防止更新版本后浏览器加载缓存
url += '?_v=' + update.VERSION
webbrowser.open(url)
logger.info('Server started: %s:%d', args.host, args.port)
logger.info('Server started: %s:%d', host, port)
tornado.ioloop.IOLoop.current().start()

205
models/avatar.py Normal file
View File

@@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import logging
import re
from typing import *
import aiohttp
import sqlalchemy
import sqlalchemy.exc
import models.database
logger = logging.getLogger(__name__)
DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
_main_event_loop = asyncio.get_event_loop()
_http_session = aiohttp.ClientSession()
# user_id -> avatar_url
_avatar_url_cache: Dict[int, str] = {}
# 正在获取头像的Futureuser_id -> Future
_uid_fetch_future_map: Dict[int, asyncio.Future] = {}
# 正在获取头像的user_id队列
_uid_queue_to_fetch = asyncio.Queue(15)
# 上次被B站ban时间
_last_fetch_banned_time: Optional[datetime.datetime] = None
def init():
asyncio.ensure_future(_get_avatar_url_from_web_consumer())
async def get_avatar_url(user_id):
avatar_url = get_avatar_url_from_memory(user_id)
if avatar_url is not None:
return avatar_url
avatar_url = await get_avatar_url_from_database(user_id)
if avatar_url is not None:
return avatar_url
avatar_url = await get_avatar_url_from_web(user_id)
if avatar_url is not None:
return avatar_url
return DEFAULT_AVATAR_URL
def get_avatar_url_from_memory(user_id):
return _avatar_url_cache.get(user_id, None)
def get_avatar_url_from_database(user_id) -> Awaitable[Optional[str]]:
return asyncio.get_event_loop().run_in_executor(
None, _do_get_avatar_url_from_database, user_id
)
def _do_get_avatar_url_from_database(user_id):
try:
with models.database.get_session() as session:
user = session.query(BilibiliUser).filter(BilibiliUser.uid == user_id).one_or_none()
if user is None:
return None
avatar_url = user.avatar_url
# 如果离上次更新太久就更新所有缓存
if (datetime.datetime.now() - user.update_time).days >= 3:
def refresh_cache():
_avatar_url_cache.pop(user_id, None)
get_avatar_url_from_web(user_id)
_main_event_loop.call_soon(refresh_cache)
else:
# 否则只更新内存缓存
_update_avatar_cache_in_memory(user_id, avatar_url)
except sqlalchemy.exc.OperationalError:
# SQLite会锁整个文件忽略就行
return None
except sqlalchemy.exc.SQLAlchemyError:
logger.exception('_do_get_avatar_url_from_database failed:')
return None
return avatar_url
def get_avatar_url_from_web(user_id) -> Awaitable[Optional[str]]:
# 如果已有正在获取的future则返回防止重复获取同一个uid
future = _uid_fetch_future_map.get(user_id, None)
if future is not None:
return future
# 否则创建一个获取任务
_uid_fetch_future_map[user_id] = future = _main_event_loop.create_future()
future.add_done_callback(lambda _future: _uid_fetch_future_map.pop(user_id, None))
try:
_uid_queue_to_fetch.put_nowait(user_id)
except asyncio.QueueFull:
future.set_result(None)
return future
async def _get_avatar_url_from_web_consumer():
while True:
try:
user_id = await _uid_queue_to_fetch.get()
future = _uid_fetch_future_map.get(user_id, None)
if future is None:
continue
# 防止在被ban的时候获取
global _last_fetch_banned_time
if _last_fetch_banned_time is not None:
cur_time = datetime.datetime.now()
if (cur_time - _last_fetch_banned_time).total_seconds() < 3 * 60 + 3:
# 3分钟以内被ban解封大约要15分钟
future.set_result(None)
continue
else:
_last_fetch_banned_time = None
asyncio.ensure_future(_get_avatar_url_from_web_coroutine(user_id, future))
# 限制频率防止被B站ban
await asyncio.sleep(0.2)
except:
logger.exception('_get_avatar_url_from_web_consumer error:')
async def _get_avatar_url_from_web_coroutine(user_id, future):
try:
avatar_url = await _do_get_avatar_url_from_web(user_id)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(avatar_url)
async def _do_get_avatar_url_from_web(user_id):
try:
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
params={'mid': user_id}) as r:
if r.status != 200:
logger.warning('Failed to fetch avatar: status=%d %s uid=%d', r.status, r.reason, user_id)
if r.status == 412:
# 被B站ban了
global _last_fetch_banned_time
_last_fetch_banned_time = datetime.datetime.now()
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
avatar_url = process_avatar_url(data['data']['face'])
update_avatar_cache(user_id, avatar_url)
return avatar_url
def process_avatar_url(avatar_url):
# 去掉协议兼容HTTP、HTTPS
m = re.fullmatch(r'(?:https?:)?(.*)', avatar_url)
if m is not None:
avatar_url = m[1]
# 缩小图片加快传输
if not avatar_url.endswith('noface.gif'):
avatar_url += '@48w_48h'
return avatar_url
def update_avatar_cache(user_id, avatar_url):
_update_avatar_cache_in_memory(user_id, avatar_url)
asyncio.get_event_loop().run_in_executor(
None, _update_avatar_cache_in_database, user_id, avatar_url
)
def _update_avatar_cache_in_memory(user_id, avatar_url):
_avatar_url_cache[user_id] = avatar_url
if len(_avatar_url_cache) > 50000:
for _, key in zip(range(100), _avatar_url_cache):
del _avatar_url_cache[key]
def _update_avatar_cache_in_database(user_id, avatar_url):
try:
with models.database.get_session() as session:
user = session.query(BilibiliUser).filter(BilibiliUser.uid == user_id).one_or_none()
if user is None:
user = BilibiliUser(uid=user_id, avatar_url=avatar_url,
update_time=datetime.datetime.now())
session.add(user)
else:
user.avatar_url = avatar_url
user.update_time = datetime.datetime.now()
session.commit()
except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.IntegrityError):
# SQLite会锁整个文件忽略就行另外还有多线程导致ID重复的问题
pass
except sqlalchemy.exc.SQLAlchemyError:
logger.exception('_update_avatar_cache_in_database failed:')
class BilibiliUser(models.database.OrmBase):
__tablename__ = 'bilibili_users'
uid = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
avatar_url = sqlalchemy.Column(sqlalchemy.Text)
update_time = sqlalchemy.Column(sqlalchemy.DateTime)

34
models/database.py Normal file
View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
import contextlib
from typing import *
import sqlalchemy.ext.declarative
import sqlalchemy.orm
import config
OrmBase = sqlalchemy.ext.declarative.declarative_base()
engine = None
DbSession: Optional[Type[sqlalchemy.orm.Session]] = None
def init(debug):
cfg = config.get_config()
global engine, DbSession
engine = sqlalchemy.create_engine(cfg.database_url, echo=debug)
DbSession = sqlalchemy.orm.sessionmaker(bind=engine)
OrmBase.metadata.create_all(engine)
@contextlib.contextmanager
def get_session():
session = DbSession()
try:
yield session
except:
session.rollback()
raise
finally:
session.close()

392
models/translate.py Normal file
View File

@@ -0,0 +1,392 @@
# -*- coding: utf-8 -*-
import asyncio
import functools
import hashlib
import logging
import random
import re
import time
import yarl
from typing import *
import aiohttp
logger = logging.getLogger(__name__)
NO_TRANSLATE_TEXTS = {
'', '草草', '草草草', '草生', '大草原', '上手', '上手上手', '理解', '理解理解', '天才', '天才天才',
'', '余裕', '余裕余裕', '大丈夫', '再放送', '放送事故'
}
_main_event_loop = asyncio.get_event_loop()
_http_session = aiohttp.ClientSession()
_translate_providers: List['TranslateProvider'] = []
# text -> res
_translate_cache: Dict[str, str] = {}
# 正在翻译的Futuretext -> Future
_text_future_map: Dict[str, asyncio.Future] = {}
def init():
asyncio.ensure_future(_do_init())
async def _do_init():
# 考虑优先级
providers = [
TencentTranslate(),
YoudaoTranslate(),
BilibiliTranslate()
]
await asyncio.gather(*(provider.init() for provider in providers))
global _translate_providers
_translate_providers = providers
def need_translate(text):
text = text.strip()
# 没有中文,平时打不出的字不管
if not any(0x4E00 <= ord(c) <= 0x9FFF for c in text):
return False
# 含有日文假名
if any(0x3040 <= ord(c) <= 0x30FF for c in text):
return False
# 弹幕同传
if text.startswith(''):
return False
# 中日双语
if text in NO_TRANSLATE_TEXTS:
return False
return True
def get_translation_from_cache(text):
key = text.strip().lower()
return _translate_cache.get(key, None)
def translate(text) -> Awaitable[Optional[str]]:
key = text.strip().lower()
# 如果已有正在翻译的future则返回防止重复翻译
future = _text_future_map.get(key, None)
if future is not None:
return future
# 否则创建一个翻译任务
future = _main_event_loop.create_future()
# 查缓存
res = _translate_cache.get(key, None)
if res is not None:
future.set_result(res)
return future
for provider in _translate_providers:
if provider.is_available:
_text_future_map[key] = future
future.add_done_callback(functools.partial(_on_translate_done, key))
provider.translate(text, future)
return future
future.set_result(None)
return future
def _on_translate_done(key, future):
_text_future_map.pop(key, None)
# 缓存
try:
res = future.result()
except:
return
if res is None:
return
_translate_cache[key] = res
if len(_translate_cache) > 50000:
for _, key in zip(range(100), _translate_cache):
del _translate_cache[key]
class TranslateProvider:
async def init(self):
return True
@property
def is_available(self):
return True
def translate(self, text, future):
raise NotImplementedError
class TencentTranslate(TranslateProvider):
def __init__(self):
self._qtv = ''
self._qtk = ''
# 连续失败的次数
self._fail_count = 0
self._cool_down_future = None
async def init(self):
try:
async with _http_session.get('https://fanyi.qq.com/') as r:
if r.status != 200:
logger.warning('TencentTranslate init request failed: status=%d %s', r.status, r.reason)
return False
html = await r.text()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('TencentTranslate init error:')
return False
m = re.search(r"""\bqtv\s*=\s*['"](.+?)['"]""", html)
if m is None:
logger.exception('TencentTranslate init failed: qtv not found')
return False
self._qtv = m[1]
m = re.search(r"""\bqtk\s*=\s*['"](.+?)['"]""", html)
if m is None:
logger.exception('TencentTranslate init failed: qtk not found')
return False
self._qtk = m[1]
return True
@property
def is_available(self):
return self._qtv != '' and self._qtk != ''
def translate(self, text, future):
asyncio.ensure_future(self._translate_coroutine(text, future))
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
self._on_fail()
return
future.set_result(res)
if res is None:
self._on_fail()
else:
self._fail_count = 0
async def _do_translate(self, text):
try:
async with _http_session.post(
'https://fanyi.qq.com/api/translate',
headers={
'Referer': 'https://fanyi.qq.com/'
},
data={
'source': 'zh',
'target': 'jp',
'sourceText': text,
'qtv': self._qtv,
'qtk': self._qtk
}
) as r:
if r.status != 200:
logger.warning('TencentTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
if data['errCode'] != 0:
logger.warning('TencentTranslate failed: %d %s', data['errCode'], data['errMsg'])
return None
return ''.join(record['targetText'] for record in data['translate']['records'])
def _on_fail(self):
self._fail_count += 1
# 目前没有测试出被ban的情况为了可靠性连续失败20次时冷却并重新init
if self._fail_count >= 20 and self._cool_down_future is None:
self._cool_down_future = asyncio.ensure_future(self._cool_down())
async def _cool_down(self):
logger.warning('TencentTranslate is cooling down')
self._qtv = self._qtk = ''
try:
while True:
await asyncio.sleep(3 * 60)
try:
is_success = self.init()
except:
logger.exception('TencentTranslate init error:')
continue
if is_success:
self._fail_count = 0
break
finally:
logger.warning('TencentTranslate finished cooling down')
self._cool_down_future = None
class YoudaoTranslate(TranslateProvider):
def __init__(self):
self._has_init = False
self._cool_down_future = None
async def init(self):
# 获取cookie
try:
async with _http_session.get('http://fanyi.youdao.com/') as r:
if r.status >= 400:
logger.warning('YoudaoTranslate init request failed: status=%d %s', r.status, r.reason)
return False
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return False
cookies = _http_session.cookie_jar.filter_cookies(yarl.URL('http://fanyi.youdao.com/'))
res = 'JSESSIONID' in cookies and 'OUTFOX_SEARCH_USER_ID' in cookies
if res:
self._has_init = True
return res
@property
def is_available(self):
return self._has_init
def translate(self, text, future):
asyncio.ensure_future(self._translate_coroutine(text, future))
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(res)
async def _do_translate(self, text):
try:
async with _http_session.post(
'http://fanyi.youdao.com/translate_o?smartresult=dict&smartresult=rule',
headers={
'Referer': 'http://fanyi.youdao.com/'
},
data={
'i': text,
'from': 'zh-CHS',
'to': 'ja',
'smartresult': 'dict',
'client': 'fanyideskweb',
**self._generate_salt(text),
'doctype': 'json',
'version': '2.1',
'keyfrom': 'fanyi.web',
'action': 'FY_BY_REALTlME'
}
) as r:
if r.status != 200:
logger.warning('YoudaoTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
except aiohttp.ContentTypeError:
# 被ban了
if self._cool_down_future is None:
self._cool_down_future = asyncio.ensure_future(self._cool_down())
return None
if data['errorCode'] != 0:
logger.warning('YoudaoTranslate failed: %d', data['errorCode'])
return None
res = []
for outer_result in data['translateResult']:
for inner_result in outer_result:
res.append(inner_result['tgt'])
return ''.join(res)
@staticmethod
def _generate_salt(text):
timestamp = int(time.time() * 1000)
salt = f'{timestamp}{random.randint(0, 9)}'
md5 = hashlib.md5()
md5.update(f'fanyideskweb{text}{salt}n%A-rKaT5fb[Gy?;N5@Tj'.encode())
sign = md5.hexdigest()
return {
'ts': timestamp,
'bv': '7bcd9ea3ff9b319782c2a557acee9179', # md5(navigator.appVersion)
'salt': salt,
'sign': sign
}
async def _cool_down(self):
logger.warning('YoudaoTranslate is cooling down')
self._has_init = False
try:
while True:
await asyncio.sleep(3 * 60)
try:
is_success = self.init()
except:
logger.exception('YoudaoTranslate init error:')
continue
if is_success:
break
finally:
logger.warning('YoudaoTranslate finished cooling down')
self._cool_down_future = None
# 目前B站后端是百度翻译
class BilibiliTranslate(TranslateProvider):
def __init__(self):
# 最长等待时间大约21秒(text, future)
self._text_queue = asyncio.Queue(7)
async def init(self):
asyncio.ensure_future(self._translate_consumer())
return True
@property
def is_available(self):
return not self._text_queue.full()
def translate(self, text, future):
try:
self._text_queue.put_nowait((text, future))
except asyncio.QueueFull:
future.set_result(None)
async def _translate_consumer(self):
while True:
try:
text, future = await self._text_queue.get()
asyncio.ensure_future(self._translate_coroutine(text, future))
# 频率限制一分钟20次
await asyncio.sleep(3.1)
except:
logger.exception('BilibiliTranslate error:')
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(res)
@staticmethod
async def _do_translate(text):
try:
async with _http_session.get(
'https://api.live.bilibili.com/av/v1/SuperChat/messageTranslate',
params={
'parent_area_id': '1',
'area_id': '199',
'msg': text
}
) as r:
if r.status != 200:
logger.warning('BilibiliTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
if data['code'] != 0:
logger.warning('BilibiliTranslate failed: %d %s', data['code'], data['msg'])
return None
return data['data']['message_trans']

View File

@@ -1,2 +1,3 @@
aiohttp==3.5.4
sqlalchemy==1.3.13
tornado==6.0.2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -1,15 +1,24 @@
# -*- coding: utf-8 -*-
import asyncio
import aiohttp
VERSION = 'v1.2.3'
VERSION = 'v1.4.1'
async def check_update():
async with aiohttp.ClientSession() as session:
async with session.get('https://api.github.com/repos/xfgryujk/blivechat/releases/latest') as r:
data = await r.json()
if data['name'] != VERSION:
print('New version available:', data['name'])
print(data['body'])
print('Download:', data['html_url'])
def check_update():
asyncio.ensure_future(_do_check_update())
async def _do_check_update():
try:
async with aiohttp.ClientSession() as session:
async with session.get('https://api.github.com/repos/xfgryujk/blivechat/releases/latest') as r:
data = await r.json()
if data['name'] != VERSION:
print('New version available:', data['name'])
print(data['body'])
print('Download:', data['html_url'])
except aiohttp.ClientConnectionError:
print('Failed to check update: connection failed')

View File

@@ -1,315 +0,0 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import enum
import json
import logging
import time
from typing import *
import aiohttp
import tornado.websocket
import blivedm.blivedm as blivedm
logger = logging.getLogger(__name__)
class Command(enum.IntEnum):
HEARTBEAT = 0
JOIN_ROOM = 1
ADD_TEXT = 2
ADD_GIFT = 3
ADD_MEMBER = 4
ADD_SUPER_CHAT = 5
DEL_SUPER_CHAT = 6
DEFAULT_AVATAR_URL = 'https://static.hdslb.com/images/member/noface.gif'
_http_session = aiohttp.ClientSession()
_avatar_url_cache: Dict[int, str] = {}
_last_fetch_avatar_time = datetime.datetime.now()
_last_avatar_failed_time = None
_uids_to_fetch_avatar = asyncio.Queue(15)
async def get_avatar_url(user_id):
if user_id in _avatar_url_cache:
return _avatar_url_cache[user_id]
global _last_avatar_failed_time, _last_fetch_avatar_time
cur_time = datetime.datetime.now()
# 防止获取头像频率太高被ban
if (cur_time - _last_fetch_avatar_time).total_seconds() < 0.2:
# 由_fetch_avatar_loop过一段时间再获取并缓存
try:
_uids_to_fetch_avatar.put_nowait(user_id)
except asyncio.QueueFull:
pass
return DEFAULT_AVATAR_URL
if _last_avatar_failed_time is not None:
if (cur_time - _last_avatar_failed_time).total_seconds() < 3 * 60 + 3:
# 3分钟以内被ban解封大约要15分钟
return DEFAULT_AVATAR_URL
else:
_last_avatar_failed_time = None
_last_fetch_avatar_time = cur_time
try:
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
params={'mid': user_id}) as r:
if r.status != 200: # 可能会被B站ban
logger.warning('Failed to fetch avatar: status=%d %s uid=%d', r.status, r.reason, user_id)
_last_avatar_failed_time = cur_time
return DEFAULT_AVATAR_URL
data = await r.json()
except aiohttp.ClientConnectionError:
return DEFAULT_AVATAR_URL
url = data['data']['face']
if not url.endswith('noface.gif'):
url += '@48w_48h'
_avatar_url_cache[user_id] = url
if len(_avatar_url_cache) > 50000:
for _, key in zip(range(100), _avatar_url_cache):
del _avatar_url_cache[key]
return url
async def _fetch_avatar_loop():
while True:
try:
user_id = await _uids_to_fetch_avatar.get()
if user_id in _avatar_url_cache:
continue
# 延时长一些使实时弹幕有机会获取头像
await asyncio.sleep(0.4 - (datetime.datetime.now() - _last_fetch_avatar_time).total_seconds())
asyncio.ensure_future(get_avatar_url(user_id))
except:
pass
asyncio.ensure_future(_fetch_avatar_loop())
class Room(blivedm.BLiveClient):
def __init__(self, room_id):
super().__init__(room_id, session=_http_session, heartbeat_interval=10)
self.clients: List['ChatHandler'] = []
def stop_and_close(self):
if self.is_running:
future = self.stop()
future.add_done_callback(lambda _future: asyncio.ensure_future(self.close()))
else:
asyncio.ensure_future(self.close())
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
for client in self.clients:
client.write_message(body)
async def _on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
asyncio.ensure_future(self.__on_receive_danmaku(danmaku))
async def __on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
if danmaku.uid == self.room_owner_uid:
author_type = 3 # 主播
elif danmaku.admin:
author_type = 2 # 房管
elif danmaku.privilege_type != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
self.send_message(Command.ADD_TEXT, {
'avatarUrl': await get_avatar_url(danmaku.uid),
'timestamp': danmaku.timestamp,
'authorName': danmaku.uname,
'authorType': author_type,
'content': danmaku.msg,
'privilegeType': danmaku.privilege_type,
'isGiftDanmaku': bool(danmaku.msg_type),
'authorLevel': danmaku.user_level,
'isNewbie': danmaku.urank < 10000,
'isMobileVerified': bool(danmaku.mobile_verify),
'medalLevel': 0 if danmaku.room_id != self.room_id else danmaku.medal_level
})
async def _on_receive_gift(self, gift: blivedm.GiftMessage):
if gift.coin_type != 'gold': # 丢人
return
self.send_message(Command.ADD_GIFT, {
'avatarUrl': gift.face,
'timestamp': gift.timestamp,
'authorName': gift.uname,
'giftName': gift.gift_name,
'giftNum': gift.num,
'totalCoin': gift.total_coin
})
async def _on_buy_guard(self, message: blivedm.GuardBuyMessage):
asyncio.ensure_future(self.__on_buy_guard(message))
async def __on_buy_guard(self, message: blivedm.GuardBuyMessage):
self.send_message(Command.ADD_MEMBER, {
'avatarUrl': await get_avatar_url(message.uid),
'timestamp': message.start_time,
'authorName': message.username
})
async def _on_super_chat(self, message: blivedm.SuperChatMessage):
self.send_message(Command.ADD_SUPER_CHAT, {
'avatarUrl': message.face,
'timestamp': message.start_time,
'authorName': message.uname,
'price': message.price,
'content': message.message,
'id': message.id
})
async def _on_super_chat_delete(self, message: blivedm.SuperChatDeleteMessage):
self.send_message(Command.ADD_SUPER_CHAT, {
'ids': message.ids
})
class RoomManager:
def __init__(self):
self._rooms: Dict[int, Room] = {}
async def add_client(self, room_id, client: 'ChatHandler'):
if room_id not in self._rooms:
if not await self._add_room(room_id):
client.close()
return
room = self._rooms[room_id]
room.clients.append(client)
logger.info('%d clients in room %s', len(room.clients), room_id)
if client.application.settings['debug']:
client.send_test_message()
def del_client(self, room_id, client: 'ChatHandler'):
if room_id not in self._rooms:
return
room = self._rooms[room_id]
room.clients.remove(client)
logger.info('%d clients in room %s', len(room.clients), room_id)
if not room.clients:
self._del_room(room_id)
async def _add_room(self, room_id):
if room_id in self._rooms:
return True
logger.info('Creating room %d', room_id)
room = Room(room_id)
self._rooms[room_id] = room
if await room.init_room():
room.start()
return True
else:
self._del_room(room_id)
return False
def _del_room(self, room_id):
if room_id not in self._rooms:
return
logger.info('Removing room %d', room_id)
room = self._rooms[room_id]
for client in room.clients:
client.close()
room.stop_and_close()
del self._rooms[room_id]
room_manager = RoomManager()
# noinspection PyAbstractClass
class ChatHandler(tornado.websocket.WebSocketHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.room_id = None
def open(self):
logger.info('Websocket connected %s', self.request.remote_ip)
def on_message(self, message):
body = json.loads(message)
cmd = body['cmd']
if cmd == Command.HEARTBEAT:
pass
elif cmd == Command.JOIN_ROOM:
if self.room_id is not None:
return
self.room_id = int(body['data']['roomId'])
logger.info('Client %s is joining room %d', self.request.remote_ip, self.room_id)
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
else:
logger.warning('Unknown cmd: %s body: %s', cmd, body)
def on_close(self):
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id))
if self.room_id is not None:
room_manager.del_client(self.room_id, self)
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
return True
return super().check_origin(origin)
# 测试用
def send_test_message(self):
base_data = {
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@48w_48h',
'timestamp': time.time(),
'authorName': 'xfgryujk',
}
text_data = {
**base_data,
'authorType': 0,
'content': '我能吞下玻璃而不伤身体',
'privilegeType': 0,
'isGiftDanmaku': False,
'authorLevel': 20,
'isNewbie': False,
'isMobileVerified': True
}
member_data = base_data
gift_data = {
**base_data,
'giftName': '摩天大楼',
'giftNum': 1,
'totalCoin': 450000
}
sc_data = {
**base_data,
'price': 30,
'content': 'The quick brown fox jumps over the lazy dog',
'id': 1
}
self.send_message(Command.ADD_TEXT, text_data)
text_data['authorName'] = '主播'
text_data['authorType'] = 3
text_data['content'] = "I can eat glass, it doesn't hurt me."
self.send_message(Command.ADD_TEXT, text_data)
self.send_message(Command.ADD_MEMBER, member_data)
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
sc_data['price'] = 100
sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗'
sc_data['id'] = 2
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
# self.send_message(Command.DEL_SUPER_CHAT, {'ids': [1, 2]})
self.send_message(Command.ADD_GIFT, gift_data)
gift_data['giftName'] = '小电视飞船'
gift_data['totalCoin'] = 1245000
self.send_message(Command.ADD_GIFT, gift_data)
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
self.write_message(body)

View File

@@ -1,65 +0,0 @@
# -*- coding: utf-8 -*-
import json
import uuid
import views.base
from typing import *
MAX_CONFIG_SIZE = 100 * 1024
configs: Dict[str, dict] = {}
# noinspection PyAbstractClass
class ConfigsHandler(views.base.ApiHandler):
async def post(self):
if not isinstance(self.json_args, dict):
self.set_status(400)
return
config = self.json_args
config_id = str(uuid.uuid4())
config['id'] = config_id
config_str = json.dumps(config)
if len(config_str) > MAX_CONFIG_SIZE:
self.set_status(413)
return
configs[config_id] = config
self.write(config_str)
self.set_status(201)
self.set_header('Content-Type', 'application/json; charset=UTF-8')
if len(configs) > 10000:
for _, key in zip(range(100), configs):
del configs[key]
# noinspection PyAbstractClass
class ConfigHandler(views.base.ApiHandler):
async def put(self, config_id):
if config_id not in configs:
self.set_status(404)
return
if not isinstance(self.json_args, dict):
self.set_status(400)
return
config = self.json_args
config['id'] = config_id
config_str = json.dumps(config)
if len(config_str) > MAX_CONFIG_SIZE:
self.set_status(413)
return
configs[config_id] = config
self.write(config_str)
self.set_header('Content-Type', 'application/json; charset=UTF-8')
async def get(self, config_id):
config = configs.get(config_id, None)
if config is None:
self.set_status(404)
return
self.write(config)

View File

@@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
import tornado.web
# noinspection PyAbstractClass
class MainHandler(tornado.web.StaticFileHandler):
"""为了使用Vue Router的history模式把所有请求转发到index.html"""
async def get(self, *args, **kwargs):
await super().get('index.html', *args, **kwargs)