Compare commits

..

21 Commits

Author SHA1 Message Date
John Smith
824e073ba2 更新版本号v1.9.3 2024-11-16 12:00:01 +08:00
John Smith
de3712a7ca 添加用于测速的接口 2024-11-12 20:09:42 +08:00
John Smith
fb9e0418c7 更新blivedm 2024-11-10 16:47:45 +08:00
John Smith
5a54f0cdaf 前端修复样式生成器中第一次加载房间后CSS不生效的问题 2024-11-10 16:45:31 +08:00
John Smith
cefca66d76 前端动态获取后端地址、支持故障转移 2024-11-08 00:18:16 +08:00
John Smith
99d3b567da 后端动态获取公共服务器地址、支持故障转移 2024-11-08 00:14:52 +08:00
John Smith
132d2a9f71 添加服务发现接口、支持跨域 2024-11-05 22:16:33 +08:00
John Smith
baa64f3dc5 支持信号重新加载配置 2024-11-05 22:16:33 +08:00
John Smith
48154b2be9 公共服务器前端不生成source map 2024-11-05 22:16:33 +08:00
John Smith
06e5481574 前端CDN改成Zstatic,添加开关 2024-11-05 22:16:33 +08:00
John Smith
8b91fa2b98 压缩前端的图片 2024-11-05 22:16:33 +08:00
John Smith
a0d1bf4742 添加Vercel配置 2024-11-05 22:16:25 +08:00
John Smith
75938141f0 docker build时NPM使用镜像 2024-07-30 00:16:36 +08:00
John Smith
187d45e6d9 更新版本号v1.9.2 2024-07-29 23:22:16 +08:00
John Smith
6d7c7b78d9 加载器改成本地文件 2024-07-29 23:21:02 +08:00
John Smith
b00ae596ef 移除腾讯翻译白嫖版 2024-07-29 22:41:38 +08:00
John Smith
e6299a03e8 前端暂时不使用CDN,因为BootCDN被uBlock Origin屏蔽了 2024-07-29 22:41:38 +08:00
dependabot[bot]
e77c26c792 Bump tornado from 6.3.3 to 6.4.1 (#207)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.3.3 to 6.4.1.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.3.3...v6.4.1)

---
updated-dependencies:
- dependency-name: tornado
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-07 09:57:37 +08:00
John Smith
a426adde65 补充注意事项链接 2024-05-26 13:14:26 +08:00
John Smith
df29586b7d 连接数限制的错误信息添加解决方法链接 2024-05-23 01:30:33 +08:00
John Smith
d5cd7f9f7e 抓一下刷请求的人 2024-05-21 20:35:46 +08:00
48 changed files with 740 additions and 367 deletions

View File

@@ -20,6 +20,7 @@ plugins/
# runtime data
data/*
!data/config.example.ini
!data/loader.html
!data/custom_public/
data/custom_public/*
!data/custom_public/README.txt

1
.gitignore vendored
View File

@@ -107,3 +107,4 @@ venv.bak/
.idea/
data/
log/
.vercel

View File

@@ -8,7 +8,7 @@ WORKDIR "${BASE_PATH}/frontend"
# 前端依赖
COPY frontend/package.json ./
RUN npm i
RUN npm i --registry=https://registry.npmmirror.com
# 编译前端
COPY frontend ./

View File

@@ -22,7 +22,9 @@
## 使用方法
以下几种方式任选一种即可
以下几种方式任选一种即可。**正式使用之前记得看[注意事项](https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98)**
推荐的方式:如果你需要使用插件、翻译等高级特性,则在本地使用;否则推荐直接通过公共服务器在线使用。因为本地使用时不会自动升级版本,有时候出了问题不能及时解决;但公共服务器会禁用部分高级特性,如果你有需要,只能本地使用了
### 一、本地使用
@@ -39,16 +41,9 @@
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
**注意事项:**
* 本地使用时不要关闭blivechat.exe那个黑框否则不能继续获取弹幕
* 如果需要使用翻译功能,建议看[配置官方翻译接口教程](https://github.com/xfgryujk/blivechat/wiki/%E9%85%8D%E7%BD%AE%E5%AE%98%E6%96%B9%E7%BF%BB%E8%AF%91%E6%8E%A5%E5%8F%A3)
### 二、公共服务器
请优先在本地使用,因为公共服务器会禁用部分特性
* [公共服务器](http://chat.bilisc.com/)
直接用浏览器打开[公共服务器](http://chat.bilisc.com/),剩下的步骤和本地使用时是一样的
### 三、源代码版自建服务器或在Windows以外平台

View File

@@ -4,18 +4,37 @@ from typing import *
import tornado.web
import config
class ApiHandler(tornado.web.RequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_args: Optional[dict] = None
def prepare(self):
def set_default_headers(self):
self.set_header('Cache-Control', 'no-cache')
self.add_header('Vary', 'Origin')
origin = self.request.headers.get('Origin', None)
if origin is None:
return
cfg = config.get_config()
if not cfg.is_allowed_cors_origin(origin):
return
self.set_header('Access-Control-Allow-Origin', origin)
self.set_header('Access-Control-Allow-Methods', '*')
self.set_header('Access-Control-Allow-Headers', '*')
self.set_header('Access-Control-Max-Age', '3600')
def prepare(self):
if not self.request.headers.get('Content-Type', '').startswith('application/json'):
return
try:
self.json_args = json.loads(self.request.body)
except json.JSONDecodeError:
pass
async def options(self, *_args, **_kwargs):
self.set_status(204)

View File

@@ -223,12 +223,13 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
self._refresh_receive_timeout_timer()
# 跨域测试用
def check_origin(self, origin):
cfg = config.get_config()
if cfg.debug:
return True
return super().check_origin(origin)
return (
cfg.debug # 开发时前端localhost直连
or cfg.is_allowed_cors_origin(origin)
or super().check_origin(origin) # 和Host相同
)
@property
def has_joined_room(self):

View File

@@ -17,7 +17,7 @@ EMOTICON_BASE_URL = '/emoticons'
CUSTOM_PUBLIC_PATH = os.path.join(config.DATA_PATH, 'custom_public')
class MainHandler(tornado.web.StaticFileHandler):
class StaticHandler(tornado.web.StaticFileHandler):
"""为了使用Vue Router的history模式把不存在的文件请求转发到index.html"""
async def get(self, path, include_body=True):
if path == '':
@@ -52,6 +52,19 @@ class ServerInfoHandler(api.base.ApiHandler):
})
class ServiceDiscoveryHandler(api.base.ApiHandler):
async def get(self):
cfg = config.get_config()
self.write({
'endpoints': cfg.registered_endpoints,
})
class PingHandler(api.base.ApiHandler):
async def get(self):
self.set_status(204)
class UploadEmoticonHandler(api.base.ApiHandler):
async def post(self):
cfg = config.get_config()
@@ -94,6 +107,8 @@ class NoCacheStaticFileHandler(tornado.web.StaticFileHandler):
ROUTES = [
(r'/api/server_info', ServerInfoHandler),
(r'/api/endpoints', ServiceDiscoveryHandler),
(r'/api/ping', PingHandler),
(r'/api/emoticon', UploadEmoticonHandler),
]
# 通配的放在最后
@@ -101,5 +116,5 @@ LAST_ROUTES = [
(rf'{EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': EMOTICON_UPLOAD_PATH}),
# 这个目录不保证文件内容不会变,还是不用缓存了
(r'/custom_public/(.*)', NoCacheStaticFileHandler, {'path': CUSTOM_PUBLIC_PATH}),
(r'/(.*)', MainHandler, {'path': config.WEB_ROOT}),
(r'/(.*)', StaticHandler, {'path': config.WEB_ROOT}),
]

View File

@@ -27,12 +27,13 @@ END_GAME_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/end'
GAME_HEARTBEAT_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/heartbeat'
GAME_BATCH_HEARTBEAT_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/batchHeartbeat'
COMMON_SERVER_BASE_URL = 'https://chat.bilisc.com'
START_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/start_game'
END_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/end_game'
GAME_HEARTBEAT_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/game_heartbeat'
START_GAME_COMMON_SERVER_URL = '/api/internal/open_live/start_game'
END_GAME_COMMON_SERVER_URL = '/api/internal/open_live/end_game'
GAME_HEARTBEAT_COMMON_SERVER_URL = '/api/internal/open_live/game_heartbeat'
_error_auth_code_cache = cachetools.LRUCache(256)
# 应B站要求抓一下刷请求的人不会用于其他用途
auth_code_room_id_cache = cachetools.LRUCache(256)
# 用于限制请求开放平台的频率
_open_live_rate_limiter = utils.rate_limit.TokenBucket(8, 8)
@@ -52,24 +53,15 @@ class BusinessError(Exception):
return self.data['code']
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict) -> dict:
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict, **kwargs) -> dict:
"""如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
cfg = config.get_config()
if cfg.is_open_live_configured:
return await request_open_live(open_live_url, body)
try:
req_ctx_mgr = utils.request.http_session.post(common_server_url, json=body)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request common server failed:')
raise
except BusinessError as e:
logger.warning('Request common server failed: %s', e)
raise
return await request_open_live(open_live_url, body, **kwargs)
return await request_common_server(common_server_url, body, **kwargs)
async def request_open_live(url, body: dict, *, ignore_rate_limit=False) -> dict:
async def request_open_live(url, body: dict, *, ignore_rate_limit=False, **kwargs) -> dict:
cfg = config.get_config()
assert cfg.is_open_live_configured
@@ -82,7 +74,7 @@ async def request_open_live(url, body: dict, *, ignore_rate_limit=False) -> dict
# 频率限制防止触发B站风控被下架
if not _open_live_rate_limiter.try_decrease_token() and not ignore_rate_limit:
raise BusinessError({'code': 4009, 'message': '接口访问限制', 'request_id': '0', 'data': None})
raise BusinessError({'code': 4009, 'message': 'BLC接口访问限制', 'request_id': '0', 'data': None})
body_bytes = json.dumps(body).encode('utf-8')
headers = {
@@ -107,25 +99,56 @@ async def request_open_live(url, body: dict, *, ignore_rate_limit=False) -> dict
headers['Accept'] = 'application/json'
try:
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes)
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes, **kwargs)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request open live failed:')
raise
except BusinessError as e:
logger.warning('Request open live failed: %s', e)
msg = str(e)
if e.code == 7010:
# 新版本日志可以截断,避免日志太长了
msg = msg[:30] + '...'
logger.warning('Request open live failed: %s', msg)
if e.code == 7007:
_error_auth_code_cache[auth_code] = True
raise
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse]) -> dict:
async def request_common_server(rel_url, body: dict, **kwargs) -> dict:
base_url, breaker = utils.request.get_common_server_base_url_and_circuit_breaker()
if base_url is None:
logger.error('No available common server endpoint')
raise TransportError('No available common server endpoint')
url = base_url + rel_url
with breaker:
try:
req_ctx_mgr = utils.request.http_session.post(url, json=body, **kwargs)
return await _read_response(req_ctx_mgr, is_common_server=True)
except TransportError:
logger.exception('Request common server failed:')
raise
except BusinessError as e:
logger.warning('Request common server failed: %s', e)
raise
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse], is_common_server=False) -> dict:
try:
async with req_ctx_mgr as r:
r.raise_for_status()
data = await r.json()
code = data['code']
if code != 0:
if code == 7010 and not is_common_server:
data['message'] += (
' 解决方法https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5'
'%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98#%E6%8A%A5%E9%94%997010-%E8%B6%85%E8%BF%87%E4%B8%8'
'A%E9%99%90%E5%90%8C%E4%B8%80%E4%B8%AA%E5%BA%94%E7%94%A8%E5%8D%95%E4%B8%AA%E7%9B%B4%E6%92%AD%'
'E9%97%B4%E6%9C%80%E5%A4%9A%E5%90%8C%E6%97%B6%E6%89%93%E5%BC%805%E4%B8%AA'
)
raise BusinessError(data)
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
@@ -140,7 +163,7 @@ def _validate_auth_code(auth_code):
):
raise BusinessError({
'code': 7007,
'message': 'oioioi!你的身份码错误了!别再重试了!!!!!!!!!!',
'message': 'CNM!你的身份码错误了!别再重试了!!!!!!!!!!',
'request_id': '0',
'data': None
})
@@ -155,6 +178,9 @@ class _OpenLiveHandlerBase(api.base.ApiHandler):
def prepare(self):
super().prepare()
if self.request.method == 'OPTIONS':
return
if not isinstance(self.json_args, dict):
raise tornado.web.MissingArgumentError('body')
@@ -210,26 +236,31 @@ class _StartGameMixin(_OpenLiveHandlerBase):
if self.res is None:
return
auth_code = self.json_args.get('code', None)
try:
room_id = self.res['data']['anchor_info']['room_id']
except (TypeError, KeyError):
room_id = None
room_id = auth_code_room_id_cache.get(auth_code, None)
else:
auth_code_room_id_cache[auth_code] = room_id
try:
game_id = self.res['data']['game_info']['game_id']
except (TypeError, KeyError):
game_id = None
code = self.res['code']
msg = self.res['message']
if code == 7010:
# 新版本日志可以截断,避免日志太长了
msg = msg[:10] + '...'
logger.info(
'client=%s room_id=%s start game res: %s %s, game_id=%s', self.request.remote_ip, room_id,
code, self.res['message'], game_id
code, msg, game_id
)
if code == 7007:
# 身份码错误
# 让我看看是哪个混蛋把房间ID、UID当做身份码
logger.info(
'client=%s auth code error! auth_code=%s', self.request.remote_ip,
self.json_args.get('code', None)
)
logger.info('client=%s auth code error! auth_code=%s', self.request.remote_ip, auth_code)
class StartGamePublicHandler(_StartGameMixin, _PublicHandlerBase):
@@ -276,9 +307,8 @@ async def send_game_heartbeat_by_service_or_common_server(game_id):
cfg = config.get_config()
if cfg.is_open_live_configured:
return await services.open_live.send_game_heartbeat(game_id)
# 这里GAME_HEARTBEAT_OPEN_LIVE_URL没用因为一定是请求公共服务器
return await request_open_live_or_common_server(
GAME_HEARTBEAT_OPEN_LIVE_URL, GAME_HEARTBEAT_COMMON_SERVER_URL, {'game_id': game_id}
return await request_common_server(
GAME_HEARTBEAT_COMMON_SERVER_URL, {'game_id': game_id}, timeout=aiohttp.ClientTimeout(total=15)
)

View File

@@ -24,7 +24,8 @@ class _AdminHandlerBase(api.base.ApiHandler):
if not cfg.enable_admin_plugins:
raise tornado.web.HTTPError(403)
logger.info('client=%s requesting admin plugin, cls=%s', self.request.remote_ip, type(self).__name__)
if self.request.method != 'OPTIONS':
logger.info('client=%s requesting admin plugin, cls=%s', self.request.remote_ip, type(self).__name__)
super().prepare()

Submodule blivedm updated: 60ce01927a...7f400e9c61

View File

@@ -2,6 +2,7 @@
import configparser
import logging
import os
import re
from typing import *
logger = logging.getLogger(__name__)
@@ -30,7 +31,7 @@ def init(cmd_args):
_config = config
def reload(cmd_args):
def reload(cmd_args=None):
config_path = ''
for path in CONFIG_PATH_LIST:
if os.path.exists(path):
@@ -73,12 +74,15 @@ class AppConfig:
self.open_live_app_id = 0
self.enable_translate = True
self.allow_translate_rooms = set()
self.allow_translate_rooms: Set[int] = set()
self.translate_max_queue_size = 10
self.translation_cache_size = 50000
self.translator_configs = []
self.translator_configs: List[dict] = []
self.text_emoticons = []
self.text_emoticons: List[dict] = []
self.registered_endpoints: List[str] = []
self.cors_origins: List[re.Pattern[str]] = []
@property
def is_open_live_configured(self):
@@ -86,7 +90,9 @@ class AppConfig:
self.open_live_access_key_id != '' and self.open_live_access_key_secret != '' and self.open_live_app_id != 0
)
def load_cmd_args(self, args):
def load_cmd_args(self, args=None):
if args is None:
return
if args.host is not None:
self.host = args.host
if args.port is not None:
@@ -101,6 +107,8 @@ class AppConfig:
self._load_app_config(config)
self._load_translator_configs(config)
self._load_text_emoticons(config)
self._load_registered_endpoints(config)
self._load_cors_origins(config)
except Exception: # noqa
logger.exception('Failed to load config:')
return False
@@ -113,6 +121,8 @@ class AppConfig:
self.database_url = app_section.get('database_url', self.database_url)
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', self.tornado_xheaders)
self.loader_url = app_section.get('loader_url', self.loader_url)
if self.loader_url == '{local_loader}':
self.loader_url = self._get_local_loader_url()
self.open_browser_at_startup = app_section.getboolean('open_browser_at_startup', self.open_browser_at_startup)
self.enable_upload_file = app_section.getboolean('enable_upload_file', self.enable_upload_file)
self.enable_admin_plugins = app_section.getboolean('enable_admin_plugins', self.enable_admin_plugins)
@@ -133,8 +143,20 @@ class AppConfig:
self.translate_max_queue_size = app_section.getint('translate_max_queue_size', self.translate_max_queue_size)
self.translation_cache_size = app_section.getint('translation_cache_size', self.translation_cache_size)
@staticmethod
def _get_local_loader_url():
url = os.path.abspath(os.path.join(DATA_PATH, 'loader.html'))
url = url.replace('\\', '/')
if not url.startswith('/'): # Windows
url = '/' + url
url = 'file://' + url
return url
def _load_translator_configs(self, config: configparser.ConfigParser):
app_section = config['app']
try:
app_section = config['app']
except KeyError:
return
section_names = _str_to_list(app_section.get('translator_configs', ''))
translator_configs = []
for section_name in section_names:
@@ -146,11 +168,13 @@ class AppConfig:
'type': type_,
'query_interval': section.getfloat('query_interval'),
}
if type_ == 'TencentTranslateFree':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
elif type_ == 'BilibiliTranslateFree':
pass
if type_ in ('TencentTranslateFree', 'BilibiliTranslateFree'):
doc_url = (
'https://github.com/xfgryujk/blivechat/wiki/%E9%85%8D%E7%BD%AE%E5%AE%98%E6%96%B9'
'%E7%BF%BB%E8%AF%91%E6%8E%A5%E5%8F%A3'
)
logger.warning('%s is deprecated, please see %s', type_, doc_url)
continue
elif type_ == 'TencentTranslate':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
@@ -178,13 +202,41 @@ class AppConfig:
self.translator_configs = translator_configs
def _load_text_emoticons(self, config: configparser.ConfigParser):
mappings_section = config['text_emoticon_mappings']
try:
mappings_section = config['text_emoticon_mappings']
except KeyError:
return
text_emoticons = []
for value in mappings_section.values():
keyword, _, url = value.partition(',')
text_emoticons.append({'keyword': keyword, 'url': url})
self.text_emoticons = text_emoticons
def _load_registered_endpoints(self, config: configparser.ConfigParser):
try:
registered_endpoints_section = config['registered_endpoints']
except KeyError:
return
registered_endpoints = list(registered_endpoints_section.values())
self.registered_endpoints = registered_endpoints
def _load_cors_origins(self, config: configparser.ConfigParser):
try:
cors_origins_section = config['cors_origins']
except KeyError:
return
cors_origins = [
re.compile(origin, re.IGNORECASE)
for origin in cors_origins_section.values()
]
self.cors_origins = cors_origins
def is_allowed_cors_origin(self, origin):
return any(
pattern.fullmatch(origin) is not None
for pattern in self.cors_origins
)
def _str_to_list(value, item_type: Type = str, container_type: Type = list):
value = value.strip()
@@ -193,5 +245,5 @@ def _str_to_list(value, item_type: Type = str, container_type: Type = list):
items = value.split(',')
items = map(lambda item: item.strip(), items)
if item_type is not str:
items = map(lambda item: item_type(item), items)
items = map(item_type, items)
return container_type(items)

View File

@@ -17,8 +17,9 @@ tornado_xheaders = false
# 加载器URL本地使用时加载器可以让你先运行OBS再运行blivechat。如果为空不使用加载器
# **自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
# “{local_loader}”表示使用本地加载器文件URL
# Use a loader so that you can run OBS before blivechat. If empty, no loader is used
loader_url = https://xfgryujk.sinacloud.net/blivechat/loader.html
loader_url = {local_loader}
# 启动时打开浏览器
# Open browser at startup
@@ -76,22 +77,8 @@ open_live_app_id = 0
# 翻译器配置,索引到下面的配置节。可以以逗号分隔配置多个翻译器,翻译时会自动负载均衡
# 配置多个翻译器可以增加额度、增加QPS、容灾
# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制
translator_configs = tencent_translate_free
[tencent_translate_free]
# 类型:腾讯翻译白嫖版。使用了网页版的接口,**将来可能失效**
type = TencentTranslateFree
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1
# 自动auto中文zh日语jp英语en韩语kr
# 完整语言列表见文档https://cloud.tencent.com/document/product/551/15619
# 源语言
source_language = zh
# 目标语言
target_language = jp
# Example: translator_configs = tencent_translate,baidu_translate
translator_configs =
[tencent_translate]
@@ -279,3 +266,13 @@ temperature = 0.4
80 = [抱拳],http://i0.hdslb.com/bfs/live/3f170894dd08827ee293afcb5a3d2b60aecdb5b1.png
81 = [给力],http://i0.hdslb.com/bfs/live/d1ba5f4c54332a21ed2ca0dcecaedd2add587839.png
82 = [耶],http://i0.hdslb.com/bfs/live/eb2d84ba623e2335a48f73fb5bef87bcf53c1239.png
# 用于服务发现返回的后端端点
[registered_endpoints]
1 = http://localhost:12450
# 允许跨域的源,正则表达式
[cors_origins]
1 = http://localhost(:\d+)

39
data/loader.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>blivechat</title>
</head>
<body>
<p>Loading... Please run blivechat</p>
<script>
function main() {
let params = new URLSearchParams(window.location.search)
let url = params.get('url')
if (!url) {
let element = document.createElement('p')
element.innerText = 'No url specified'
document.body.appendChild(element)
return
}
let timerId = null
function poll() {
window.fetch(url, {mode: 'no-cors'}).then(
() => {
window.clearInterval(timerId)
window.location.href = url
},
() => {}
)
}
timerId = window.setInterval(poll, 1000)
poll()
}
main()
</script>
</body>
</html>

6
frontend/.env Normal file
View File

@@ -0,0 +1,6 @@
# 第三方库用CDN引入
LIB_USE_CDN=false
# production环境生成source map
PROD_SOURCE_MAP=true
# 动态发现后端endpoint
BACKEND_DISCOVERY=false

View File

@@ -0,0 +1,4 @@
NODE_ENV=production
LIB_USE_CDN=true
PROD_SOURCE_MAP=false
BACKEND_DISCOVERY=true

1
frontend/.gitignore vendored
View File

@@ -5,6 +5,7 @@ node_modules
# local env files
.env.local
.env.*.local
!.env
# Log files
npm-debug.log*

View File

@@ -1,10 +1,11 @@
{
"name": "blivechat",
"version": "1.9.1",
"version": "1.9.3",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"build_common_server": "vue-cli-service build --mode common_server",
"lint": "vue-cli-service lint"
},
"dependencies": {
@@ -14,6 +15,7 @@
"downloadjs": "^1.4.7",
"element-ui": "^2.15.13",
"lodash": "^4.17.21",
"opossum": "^8.3.0",
"pako": "^2.1.0",
"vue": "^2.7.14",
"vue-i18n": "^8.28.2",

View File

@@ -11,23 +11,29 @@
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>blivechat</title>
<% if (process.env.NODE_ENV === 'production') { %>
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.14/vue.runtime.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue-router/3.6.5/vue-router.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.min.js"></script>
<link href="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.min.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/index.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/pako/2.1.0/pako_inflate.min.js"></script>
<%
if (process.env.LIB_USE_CDN) {
if (process.env.NODE_ENV === 'production') {
%>
<script src="https://s4.zstatic.net/ajax/libs/lodash.js/4.17.21/lodash.min.js" integrity="sha384-H6KKS1H1WwuERMSm+54dYLzjg0fKqRK5ZRyASdbrI/lwrCc6bXEmtGYr5SwvP1pZ" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue/2.7.14/vue.runtime.min.js" integrity="sha384-OuPzRFb9+YLr+ulVAQiUKWAYfQdbHQI3IvQ86+ApeLgPgTI2EJ/ySfV9UjvFFhxN" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-router/3.6.5/vue-router.min.js" integrity="sha384-/NExOjjk+Jjk5ZvMGKDUq/QxHVEJxhJXBBEb9AVSjc1zeQUYhG9cLvz1pY1/QrQF" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.min.js" integrity="sha384-hcSOWoIpDyRLasgG/oDA+pDX1b7k6FX+8BkVxPvKXZ/w1ntszakmyGEJXgMeuWCU" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.min.css" integrity="sha384-I0kZgnELdQIeth/GMZEO2WQ1Po7jSQEkkqnh7bT+ofoJ9FVqFDhxeu7I10K5DyuV" crossorigin="anonymous">
<script src="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/index.min.js" integrity="sha384-Wzk9c33au7LnMJ233iNheTpEpVPeseLtoqnJElShswILwuk4JaiHjpj6fKD2yhoV" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/pako/2.1.0/pako_inflate.min.js" integrity="sha384-taEjHL+GUvC8IhXeCaTNUxz3O8ItsajGFLDFj4v/VJvM24HU36qP/6sRpuAGGfeT" crossorigin="anonymous"></script>
<% } else { %>
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.14/vue.runtime.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue-router/3.6.5/vue-router.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.js"></script>
<link href="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/index.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/pako/2.1.0/pako_inflate.js"></script>
<% } %>
<script src="https://s4.zstatic.net/ajax/libs/lodash.js/4.17.21/lodash.js" integrity="sha384-l3ZPesZ3gDMDOrzjEodAMRyQlQnAR6KFZN2hnIr+h8Y80fuKlD1jsjdxJpKr9XgP" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue/2.7.14/vue.runtime.js" integrity="sha384-7Eh7MYtBZnl23Wa8pdx0AXtu35xsvTaEW6ZXBaFGIND87X3ZvFyqUffgUXa2D+HM" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-router/3.6.5/vue-router.js" integrity="sha384-IkMvPbEBTaZXtDP8XKR0vzQv6h12mn1Tsm0YRzlJ5yZQtQ0b0fI2upc3EB5erSWp" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.js" integrity="sha384-6JdClPjeS6N/byLmSMRUkfj8qIW6PR8InDu0hwcpUGithNg6/3+h6QpYNqB9LXXD" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.css" integrity="sha384-i3Zi0wHs7p1SlIuNOWAJupwQHyh8Vg54HE50dXe6SiJwHglCpbLr5Nd9wiswohGS" crossorigin="anonymous">
<script src="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/index.js" integrity="sha384-xJNO3hLLbOGPVKrsu1WIty+KkzNuJKdRr4blB8iY29iXAqkeGJWBtHZ0DGCYi/OE" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/pako/2.1.0/pako_inflate.js" integrity="sha384-uHURdoyk6Dqn/gSq38WU6iUc3W0A1rnHIKEp5sjIz8w4yz1eKIM8N7WvNwKMzALi" crossorigin="anonymous"></script>
<%
}
}
%>
</head>
<body>
<noscript>

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

171
frontend/src/api/base.js Normal file
View File

@@ -0,0 +1,171 @@
import axios from 'axios'
import _ from 'lodash'
import CircuitBreaker from 'opossum'
axios.defaults.timeout = 10 * 1000
export const apiClient = axios.create({
timeout: 10 * 1000,
})
export let init
export let getBaseUrl
if (!process.env.BACKEND_DISCOVERY) {
init = async function() {}
const onRequest = config => {
config.baseURL = getBaseUrl()
return config
}
const onRequestError = e => {
throw e
}
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
getBaseUrl = function() {
return window.location.origin
}
} else {
init = async function() {
return updateBaseUrls()
}
const onRequest = config => {
let baseUrl = getBaseUrl()
if (baseUrl === null) {
throw new Error('No available endpoint')
}
config.baseURL = baseUrl
return config
}
const onRequestError = e => {
throw e
}
const onResponse = response => {
let promise = Promise.resolve(response)
let baseUrl = response.config.baseURL
let breaker = getOrAddCircuitBreaker(baseUrl)
breaker.fire(promise).catch(() => {})
return response
}
const onResponseError = e => {
let promise = Promise.reject(e)
if (!e.response || (500 <= e.response.status && e.response.status < 600)) {
let baseUrl = e.config.baseURL
let breaker = getOrAddCircuitBreaker(baseUrl)
breaker.fire(promise).catch(() => {})
}
return promise
}
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
apiClient.interceptors.response.use(onResponse, onResponseError)
const DISCOVERY_URLS = process.env.NODE_ENV === 'production' ? [
// 只有公共服务器会开BACKEND_DISCOVERY这里可以直接跨域访问
'https://api1.blive.chat/api/endpoints',
'https://api2.blive.chat/api/endpoints',
] : [
`${window.location.origin}/api/endpoints`,
'http://localhost:12450/api/endpoints',
]
let baseUrls = process.env.NODE_ENV === 'production' ? [
'https://api1.blive.chat',
'https://api2.blive.chat',
] : [
window.location.origin,
'http://localhost:12450',
]
let curBaseUrl = null
let baseUrlToCircuitBreaker = new Map()
const doUpdateBaseUrls = async() => {
async function requestGetUrls(discoveryUrl) {
try {
return (await axios.get(discoveryUrl)).data.endpoints
} catch (e) {
console.warn('Failed to discover server endpoints from one source:', e)
throw e
}
}
let _baseUrls = []
try {
let promises = DISCOVERY_URLS.map(requestGetUrls)
_baseUrls = await Promise.any(promises)
} catch {
}
if (_baseUrls.length === 0) {
console.error('Failed to discover server endpoints from any source')
return
}
// 按响应时间排序
let sortedBaseUrls = []
let errorBaseUrls = []
async function testEndpoint(baseUrl) {
try {
let url = `${baseUrl}/api/ping`
await axios.get(url, { timeout: 3 * 1000 })
sortedBaseUrls.push(baseUrl)
} catch {
errorBaseUrls.push(baseUrl)
}
}
await Promise.all(_baseUrls.map(testEndpoint))
sortedBaseUrls = sortedBaseUrls.concat(errorBaseUrls)
baseUrls = sortedBaseUrls
if (baseUrls.indexOf(curBaseUrl) === -1) {
curBaseUrl = null
}
console.log('Found server endpoints:', baseUrls)
}
const updateBaseUrls = _.throttle(doUpdateBaseUrls, 3 * 60 * 1000)
getBaseUrl = function() {
updateBaseUrls()
if (curBaseUrl !== null) {
let breaker = getOrAddCircuitBreaker(curBaseUrl)
if (!breaker.opened) {
return curBaseUrl
}
curBaseUrl = null
}
// 找第一个未熔断的
for (let baseUrl of baseUrls) {
let breaker = getOrAddCircuitBreaker(baseUrl)
if (!breaker.opened) {
curBaseUrl = baseUrl
console.log('Switch server endpoint to', curBaseUrl)
return curBaseUrl
}
}
return null
}
const getOrAddCircuitBreaker = baseUrl => {
let breaker = baseUrlToCircuitBreaker.get(baseUrl)
if (breaker === undefined) {
breaker = new CircuitBreaker(promise => promise, {
timeout: false,
rollingCountTimeout: 60 * 1000,
errorThresholdPercentage: 70,
resetTimeout: 60 * 1000,
})
baseUrlToCircuitBreaker.set(baseUrl, breaker)
}
return breaker
}
}

View File

@@ -1,5 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
import * as base from './ChatClientOfficialBase'

View File

@@ -1,5 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
import * as base from './ChatClientOfficialBase'

View File

@@ -1,3 +1,4 @@
import { getBaseUrl } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
@@ -52,8 +53,15 @@ export default class ChatClientRelay {
this.addDebugMsg('Connecting')
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${protocol}://${window.location.host}/api/chat`
let baseUrl = getBaseUrl()
if (baseUrl === null) {
this.addDebugMsg('No available endpoint')
window.setTimeout(() => this.onWsClose(), 0)
return
}
let url = baseUrl.replace(/^http(s?):/, 'ws$1:')
url += '/api/chat'
this.websocket = new WebSocket(url)
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)

View File

@@ -1,6 +1,7 @@
import axios from 'axios'
import MD5 from 'crypto-js/md5'
import { apiClient as axios } from '@/api/base'
export function getDefaultMsgHandler() {
let dummyFunc = () => {}
return {

View File

@@ -1,4 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from './base'
export async function getServerInfo() {
return (await axios.get('/api/server_info')).data

View File

@@ -1,4 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from './base'
export async function getPlugins() {
return (await axios.get('/api/plugin/plugins')).data

View File

@@ -1,3 +1,4 @@
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import zh from '@/lang/zh'
@@ -5,6 +6,10 @@ import zh from '@/lang/zh'
let lastSetLocale = 'zh'
let loadedLocales = ['zh']
if (!process.env.LIB_USE_CDN) {
Vue.use(VueI18n)
}
export async function setLocale(locale) {
lastSetLocale = locale
if (loadedLocales.indexOf(locale) === -1) {

View File

@@ -1,12 +1,19 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import axios from 'axios'
import ElementUI from 'element-ui'
if (!process.env.LIB_USE_CDN) {
import('element-ui/lib/theme-chalk/index.css')
}
import * as apiBase from './api/base'
import * as i18n from './i18n'
import App from './App'
import NotFound from './views/NotFound'
axios.defaults.timeout = 10 * 1000
if (!process.env.LIB_USE_CDN) {
Vue.use(VueRouter)
Vue.use(ElementUI)
}
Vue.config.ignoredElements = [
/^yt-/
@@ -54,6 +61,8 @@ const router = new VueRouter({
]
})
await apiBase.init()
new Vue({
render: h => h(App),
router,

View File

@@ -2,18 +2,18 @@
<div>
<h1>{{ $t('help.help') }}</h1>
<p>{{ $t('help.p1_1') }} <a href="https://play-live.bilibili.com/" target="_blank">https://play-live.bilibili.com/</a> {{ $t('help.p1_2') }}</p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.jpg"></el-image></p>
<p>{{ $t('help.p2') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.png"></el-image></p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.jpg"></el-image></p>
<p>{{ $t('help.p3') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-3.png"></el-image></p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-3.jpg"></el-image></p>
<p>{{ $t('help.p4') }}</p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-4.png"></el-image></p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-4.jpg"></el-image></p>
<p>{{ $t('help.p5') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-5.png"></el-image></p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-5.jpg"></el-image></p>
<p><br><br><br><br><br><br><br><br>--------------------------------------------------------------------------------------------------------</p>
<p>使用前必看<a href="https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98" target="_blank">注意事项和常见问题</a></p>
<p>喜欢的话可以推荐给别人 _(:з)_</p>
<p>如果需要使用翻译功能建议看<a href="https://www.bilibili.com/read/cv14663633" target="_blank">配置官方翻译接口傻瓜式教程</a>注意必须下载到本地才能改后台配置本地使用方法看<a href="https://github.com/xfgryujk/blivechat#%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95" target="_blank">项目地址中的使用方法说明</a></p>
</div>
</template>

View File

@@ -142,6 +142,8 @@ export default {
message: 'Loaded',
duration: 500
})
this.sendMessageToStylegen('stylegenExampleRoomLoad')
},
initConfig() {
let locale = this.strConfig.lang
@@ -220,8 +222,18 @@ export default {
this.textEmoticons = await chat.getTextEmoticons()
},
sendMessageToStylegen(type, data = null) {
if (window.parent === window) {
return
}
let msg = { type, data }
window.parent.postMessage(msg, window.location.origin)
},
// 处理样式生成器发送的消息
onWindowMessage(event) {
if (event.source !== window.parent) {
return
}
if (event.origin !== window.location.origin) {
console.warn(`消息origin错误${event.origin} != ${window.location.origin}`)
return

View File

@@ -38,9 +38,7 @@
</el-form-item>
</el-form>
<div id="example-container" :class="{ light: exampleBgLight }">
<iframe id="example-room-iframe" ref="exampleRoomIframe"
:src="exampleRoomUrl" frameborder="0" @load="onExampleRoomLoad"
></iframe>
<iframe id="example-room-iframe" ref="exampleRoomIframe" :src="exampleRoomUrl" frameborder="0"></iframe>
</div>
</div>
</el-col>
@@ -96,22 +94,42 @@ export default {
},
mounted() {
this.debounceResult = this.inputResult = this.subComponentResult
window.addEventListener('message', this.onWindowMessage)
},
beforeDestroy() {
window.removeEventListener('message', this.onWindowMessage)
},
methods: {
onExampleRoomLoad() {
this.setExampleRoomCustomCss(this.debounceResult)
this.setExampleRoomClientStart(this.playAnimation)
sendMessageToExampleRoom(type, data = null) {
let msg = { type, data }
this.$refs.exampleRoomIframe.contentWindow.postMessage(msg, window.location.origin)
},
// 处理房间发送的消息
onWindowMessage(event) {
if (event.source !== this.$refs.exampleRoomIframe.contentWindow) {
return
}
if (event.origin !== window.location.origin) {
console.warn(`消息origin错误${event.origin} != ${window.location.origin}`)
return
}
let { type } = event.data
switch (type) {
case 'stylegenExampleRoomLoad':
this.setExampleRoomCustomCss(this.debounceResult)
this.setExampleRoomClientStart(this.playAnimation)
break
}
},
setExampleRoomCustomCss(css) {
this.sendMessageToExampleRoom('roomSetCustomStyle', { css })
},
setExampleRoomClientStart(isStart) {
this.sendMessageToExampleRoom(isStart ? 'roomStartClient' : 'roomStopClient')
},
sendMessageToExampleRoom(type, data = null) {
let msg = { type, data }
this.$refs.exampleRoomIframe.contentWindow.postMessage(msg, window.location.origin)
},
copyResult() {
this.$refs.result.select()

31
frontend/vercel.json Normal file
View File

@@ -0,0 +1,31 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"cleanUrls": true,
"trailingSlash": false,
"headers": [
{
"source": "/((?!api/)[^.]*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=180"
}
]
},
{
"source": "/((?!api/).+\\.\\w+)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=86400"
}
]
}
],
"rewrites": [
{
"source": "/((?!api/)[^.]+)",
"destination": "/"
}
]
}

View File

@@ -1,7 +1,16 @@
const { defineConfig } = require('@vue/cli-service')
// 不能用localhosthttps://forum.dfinity.org/t/development-workflow-quickly-test-code-modifications/1793/21
const API_BASE_URL = 'http://127.0.0.1:12450'
module.exports = {
function toBool(val) {
if (typeof val === 'string') {
return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
}
return Boolean(val)
}
module.exports = defineConfig({
devServer: {
proxy: {
'/api': {
@@ -16,24 +25,35 @@ module.exports = {
},
}
},
productionSourceMap: toBool(process.env.PROD_SOURCE_MAP),
chainWebpack: config => {
const APP_VERSION = `v${process.env.npm_package_version}`
const LIB_USE_CDN = toBool(process.env.LIB_USE_CDN)
const ENV = {
APP_VERSION,
LIB_USE_CDN,
BACKEND_DISCOVERY: toBool(process.env.BACKEND_DISCOVERY),
}
config.plugin('define')
.tap(args => {
let defineMap = args[0]
let env = defineMap['process.env']
env['APP_VERSION'] = JSON.stringify(APP_VERSION)
for (let [name, value] of Object.entries(ENV)) {
env[name] = JSON.stringify(value)
}
return args
})
config.externals({
'element-ui': 'ELEMENT',
lodash: '_',
pako: 'pako',
vue: 'Vue',
'vue-router': 'VueRouter',
'vue-i18n': 'VueI18n',
})
if (LIB_USE_CDN) {
config.externals({
'element-ui': 'ELEMENT',
lodash: '_',
pako: 'pako',
vue: 'Vue',
'vue-router': 'VueRouter',
'vue-i18n': 'VueI18n',
})
}
}
}
})

43
main.py
View File

@@ -39,6 +39,7 @@ ROUTES = [
server: Optional[tornado.httpserver.HTTPServer] = None
cmd_args = None
shut_down_event: Optional[asyncio.Event] = None
@@ -55,11 +56,12 @@ async def main():
def init():
init_signal_handlers()
args = parse_args()
global cmd_args
cmd_args = parse_args()
init_logging(args.debug)
init_logging(cmd_args.debug)
logger.info('App started, initializing')
config.init(args)
config.init(cmd_args)
utils.request.init()
models.database.init()
@@ -83,21 +85,36 @@ def init_signal_handlers():
global shut_down_event
shut_down_event = asyncio.Event()
signums = (signal.SIGINT, signal.SIGTERM)
try:
loop = asyncio.get_running_loop()
for signum in signums:
loop.add_signal_handler(signum, on_shut_down_signal)
except NotImplementedError:
# 不太安全但Windows只能用这个
for signum in signums:
signal.signal(signum, on_shut_down_signal)
is_win = sys.platform == 'win32'
loop = asyncio.get_running_loop()
if not is_win:
def add_signal_handler(signum, callback):
loop.add_signal_handler(signum, callback)
else:
def add_signal_handler(signum, callback):
# 不太安全但Windows只能用这个
signal.signal(signum, lambda _signum, _frame: loop.call_soon(callback))
shut_down_signums = (signal.SIGINT, signal.SIGTERM)
if not is_win:
reload_signum = signal.SIGHUP
else:
reload_signum = signal.SIGBREAK
for shut_down_signum in shut_down_signums:
add_signal_handler(shut_down_signum, on_shut_down_signal)
add_signal_handler(reload_signum, on_reload_signal)
def on_shut_down_signal(*_args):
def on_shut_down_signal():
shut_down_event.set()
def on_reload_signal():
logger.info('Received reload signal')
config.reload(cmd_args)
def parse_args():
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播评论栏')
parser.add_argument('--host', help='服务器host默认和配置中的一样', default=None)

View File

@@ -1,5 +1,6 @@
-r blivedm/requirements.txt
cachetools==5.3.1
circuitbreaker==2.0.0
pycryptodome==3.19.1
sqlalchemy==2.0.19
tornado==6.3.3
tornado==6.4.1

View File

@@ -266,7 +266,10 @@ class OpenLiveClient(blivedm.OpenLiveClient):
logger.error('_start_game() failed')
return False
except api_open_live.BusinessError as e:
logger.warning('_start_game() failed')
logger.warning(
'_start_game() failed, room_id=%s',
api_open_live.auth_code_room_id_cache.get(self._room_owner_auth_code, None)
)
if e.code == 7007:
# 身份码错误
@@ -288,7 +291,11 @@ class OpenLiveClient(blivedm.OpenLiveClient):
})
return False
return self._parse_start_game(data['data'])
res = self._parse_start_game(data['data'])
if res:
api_open_live.auth_code_room_id_cache[self._room_owner_auth_code] = self.room_id
return res
async def _end_game(self):
if self._game_id in (None, ''):

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
import asyncio
import base64
import dataclasses
import datetime
import enum
@@ -10,7 +9,6 @@ import hmac
import json
import logging
import random
import re
from typing import *
import Crypto.Cipher.AES as cry_aes # noqa
@@ -71,11 +69,7 @@ async def _do_init():
def create_translate_provider(cfg):
type_ = cfg['type']
if type_ == 'TencentTranslateFree':
return TencentTranslateFree(
cfg['query_interval'], cfg['source_language'], cfg['target_language']
)
elif type_ == 'TencentTranslate':
if type_ == 'TencentTranslate':
return TencentTranslate(
cfg['query_interval'], cfg['source_language'], cfg['target_language'],
cfg['secret_id'], cfg['secret_key'], cfg['region']
@@ -301,214 +295,6 @@ class TranslateProvider:
raise NotImplementedError
class TencentTranslateFree(TranslateProvider):
def __init__(self, query_interval, source_language, target_language):
super().__init__(query_interval)
self._be_available_event.clear() # _do_init之后才可用
self._source_language = source_language
self._target_language = target_language
self._server_time_delta = 0
self._uc_key = self._uc_iv = ''
self._qtv = self._qtk = ''
self._reinit_future = None
# 连续失败的次数
self._fail_count = 0
async def init(self):
if not await super().init():
return False
self._reinit_future = asyncio.create_task(self._reinit_coroutine())
return True
async def _do_init(self):
try:
async with utils.request.http_session.get('https://fanyi.qq.com/') as r:
if r.status != 200:
logger.warning('TencentTranslateFree init request failed: status=%d %s', r.status, r.reason)
return False
html = await r.text()
try:
server_time = r.headers['Date']
server_time = datetime.datetime.strptime(server_time, '%a, %d %b %Y %H:%M:%S GMT')
server_time = server_time.replace(tzinfo=datetime.timezone.utc).timestamp()
self._server_time_delta = int((datetime.datetime.now().timestamp() - server_time) * 1000)
except (KeyError, ValueError):
self._server_time_delta = 0
except (aiohttp.ClientError, asyncio.TimeoutError):
logger.exception('TencentTranslateFree init error:')
return False
# 获取token URL
m = re.search(r"""\breauthuri\s*=\s*['"](.+?)['"]""", html)
if m is None:
logger.exception('TencentTranslateFree init failed: reauthuri not found')
return False
reauthuri = m[1]
# 获取验证用的key、iv
m = re.search(r"""\s*=\s*['"]((?:\w+\|\w+-)+\w+\|\w+)['"]""", html)
if m is None:
logger.exception('TencentTranslateFree init failed: initial global variables not found')
return False
uc_key = None
uc_iv = None
for item in m[1].split('-'):
key, _, value = item.partition('|')
if key == 'a137':
uc_key = value
elif key == 'E74':
uc_iv = value
if uc_key is not None and uc_iv is not None:
break
# 获取token
try:
async with utils.request.http_session.post('https://fanyi.qq.com/api/' + reauthuri) as r:
if r.status != 200:
logger.warning('TencentTranslateFree init request failed: reauthuri=%s, status=%d %s',
reauthuri, r.status, r.reason)
return False
data = await r.json()
except (aiohttp.ClientError, asyncio.TimeoutError):
logger.exception('TencentTranslateFree init error:')
return False
qtv = data.get('qtv', None)
if qtv is None:
logger.warning('TencentTranslateFree init failed: qtv not found')
return False
qtk = data.get('qtk', None)
if qtk is None:
logger.warning('TencentTranslateFree init failed: qtk not found')
return False
self._uc_key = uc_key
self._uc_iv = uc_iv
self._qtv = qtv
self._qtk = qtk
self._on_availability_change()
return True
async def _reinit_coroutine(self):
while True:
logger.debug('TencentTranslateFree reinit')
start_time = datetime.datetime.now()
try:
await self._do_init()
except Exception: # noqa
pass
cost_time = (datetime.datetime.now() - start_time).total_seconds()
await asyncio.sleep(30 - cost_time)
@property
def is_available(self):
return '' not in (self._uc_key, self._uc_iv, self._qtv, self._qtk) and super().is_available
async def _translate_wrapper(self, task: TranslateTask) -> Optional[str]:
res = await super()._translate_wrapper(task)
if res is not None:
self._fail_count = 0
else:
self._on_fail()
return res
async def _do_translate(self, text) -> Optional[str]:
try:
async with utils.request.http_session.post(
'https://fanyi.qq.com/api/translate',
headers={
'Referer': 'https://fanyi.qq.com/',
'uc': self._get_uc()
},
data={
'source': self._source_language,
'target': self._target_language,
'sourceText': text,
'qtv': self._qtv,
'qtk': self._qtk
}
) as r:
if r.status != 200:
logger.warning('TencentTranslateFree request failed: status=%d %s', r.status, r.reason)
return None
self._update_uc_key(r)
data = await r.json()
except (aiohttp.ClientError, asyncio.TimeoutError):
return None
if data['errCode'] != 0:
logger.warning('TencentTranslateFree failed: %d %s', data['errCode'], data['errMsg'])
return None
res = ''.join(record['targetText'] for record in data['translate']['records'])
if res == '' and text.strip() != '':
# qtv、qtk过期
logger.info('TencentTranslateFree result is empty %s', data)
return None
return res
def _get_uc(self):
user_actions = self._gen_user_actions()
cur_timestamp = str(int(datetime.datetime.now().timestamp() * 1000))
server_time_delta = str(self._server_time_delta)
uc = '|'.join([user_actions, cur_timestamp, server_time_delta])
aes = cry_aes.new(self._uc_key.encode('utf-8'), cry_aes.MODE_CBC, self._uc_iv.encode('utf-8'))
uc = cry_pad.pad(uc.encode('utf-8'), aes.block_size, 'pkcs7')
uc = aes.encrypt(uc)
uc = base64.b64encode(uc).decode('utf-8')
return uc
@staticmethod
def _gen_user_actions():
# 1点击翻译2源输入框聚焦或失去焦点3点击源语言列表4点击交换语言5点击目标语言列表6源输入框输入、粘贴
user_actions = []
if random.randint(1, 5) == 1:
for i in range(random.randint(1, 2)):
user_actions.append('2')
user_actions.append('6')
for i in range(random.randint(0, 6)):
user_actions.append(random.choice('26'))
if random.randint(1, 5) == 1:
user_actions.append('1')
return ''.join(user_actions)
def _update_uc_key(self, r):
try:
hf_f = r.headers['f']
hf_ts = int(r.headers['ts'])
except (KeyError, ValueError):
return
cur_timestamp = int(datetime.datetime.now().timestamp() * 1000)
hf_f = base64.b64decode(hf_f.encode('utf-8')).decode('utf-8')
pos = int(hf_f[72: 72 + 4])
uc_key = hf_f[pos: pos + 16]
uc_iv = hf_f[pos + 16: pos + 16 + 16]
self._server_time_delta = cur_timestamp - hf_ts
self._uc_key = uc_key
self._uc_iv = uc_iv
def _on_fail(self):
self._fail_count += 1
# 为了可靠性连续失败5次时冷却直到下次重新init
if self._fail_count >= 5:
self._cool_down()
def _cool_down(self):
logger.info('TencentTranslateFree is cooling down')
# 下次_do_init后恢复
self._uc_key = self._uc_iv = ''
self._qtv = self._qtk = ''
self._fail_count = 0
self._on_availability_change()
class TencentTranslate(TranslateProvider):
def __init__(self, query_interval, source_language, target_language,
secret_id, secret_key, region):

View File

@@ -6,7 +6,7 @@ import aiohttp
import utils.async_io
import utils.request
VERSION = 'v1.9.1'
VERSION = 'v1.9.3'
def check_update():

View File

@@ -1,8 +1,17 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import logging
from typing import *
import aiohttp
import circuitbreaker
import api.open_live
import config
import utils.async_io
logger = logging.getLogger(__name__)
# 不带这堆头部有时候也能成功请求,但是带上后成功的概率更高
BILIBILI_COMMON_HEADERS = {
@@ -14,6 +23,18 @@ BILIBILI_COMMON_HEADERS = {
http_session: Optional[aiohttp.ClientSession] = None
_COMMON_SERVER_DISCOVERY_URLS = [
'https://api1.blive.chat/api/endpoints',
'https://api2.blive.chat/api/endpoints',
]
_last_update_common_server_time: Optional[datetime.datetime] = None
_common_server_base_urls = [
'https://api1.blive.chat',
'https://api2.blive.chat',
]
_cur_common_server_base_url: Optional[str] = None
_common_server_base_url_to_circuit_breaker: Dict[str, circuitbreaker.CircuitBreaker] = {}
def init():
global http_session
@@ -22,6 +43,10 @@ def init():
timeout=aiohttp.ClientTimeout(total=10),
)
cfg = config.get_config()
if not cfg.is_open_live_configured:
_update_common_server_base_urls()
async def shut_down():
if http_session is not None:
@@ -35,3 +60,97 @@ class CustomClientResponse(aiohttp.ClientResponse):
return await super()._wait_released()
except asyncio.CancelledError as e:
raise aiohttp.ClientConnectionError('Connection released') from e
def _update_common_server_base_urls():
global _last_update_common_server_time
cur_time = datetime.datetime.now()
if (
_last_update_common_server_time is not None
and cur_time - _last_update_common_server_time < datetime.timedelta(minutes=3)
):
return
_last_update_common_server_time = cur_time
utils.async_io.create_task_with_ref(_do_update_common_server_base_urls())
async def _do_update_common_server_base_urls():
global _last_update_common_server_time
_last_update_common_server_time = datetime.datetime.now()
async def request_get_urls(discovery_url):
async with http_session.get(discovery_url) as res:
res.raise_for_status()
data = await res.json()
return data['endpoints']
common_server_base_urls = []
futures = [
asyncio.create_task(request_get_urls(url))
for url in _COMMON_SERVER_DISCOVERY_URLS
]
for future in asyncio.as_completed(futures):
try:
common_server_base_urls = await future
break
except Exception as e:
logger.warning('Failed to discover common server endpoints from one source: %s', e)
for future in futures:
future.cancel()
if not common_server_base_urls:
logger.error('Failed to discover common server endpoints from any source')
return
# 按响应时间排序
sorted_common_server_base_urls = []
error_base_urls = []
async def test_endpoint(base_url):
try:
url = base_url + '/api/ping'
async with http_session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as res:
res.raise_for_status()
sorted_common_server_base_urls.append(base_url)
except Exception: # noqa
error_base_urls.append(base_url)
await asyncio.gather(*(test_endpoint(base_url) for base_url in common_server_base_urls))
sorted_common_server_base_urls.extend(error_base_urls)
global _common_server_base_urls, _cur_common_server_base_url
_common_server_base_urls = sorted_common_server_base_urls
if _cur_common_server_base_url not in _common_server_base_urls:
_cur_common_server_base_url = None
logger.info('Found common server endpoints: %s', _common_server_base_urls)
def get_common_server_base_url_and_circuit_breaker() -> Tuple[Optional[str], Optional[circuitbreaker.CircuitBreaker]]:
_update_common_server_base_urls()
global _cur_common_server_base_url
if _cur_common_server_base_url is not None:
breaker = _get_or_add_common_server_circuit_breaker(_cur_common_server_base_url)
if breaker.state != circuitbreaker.STATE_OPEN:
return _cur_common_server_base_url, breaker
_cur_common_server_base_url = None
# 找第一个未熔断的
for base_url in _common_server_base_urls:
breaker = _get_or_add_common_server_circuit_breaker(base_url)
if breaker.state != circuitbreaker.STATE_OPEN:
_cur_common_server_base_url = base_url
logger.info('Switch common server endpoint to %s', _cur_common_server_base_url)
return _cur_common_server_base_url, breaker
return None, None
def _get_or_add_common_server_circuit_breaker(base_url):
breaker = _common_server_base_url_to_circuit_breaker.get(base_url, None)
if breaker is None:
breaker = _common_server_base_url_to_circuit_breaker[base_url] = circuitbreaker.CircuitBreaker(
failure_threshold=3,
recovery_timeout=60,
expected_exception=api.open_live.TransportError,
)
return breaker