Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
824e073ba2 | ||
|
|
de3712a7ca | ||
|
|
fb9e0418c7 | ||
|
|
5a54f0cdaf | ||
|
|
cefca66d76 | ||
|
|
99d3b567da | ||
|
|
132d2a9f71 | ||
|
|
baa64f3dc5 | ||
|
|
48154b2be9 | ||
|
|
06e5481574 | ||
|
|
8b91fa2b98 | ||
|
|
a0d1bf4742 | ||
|
|
75938141f0 | ||
|
|
187d45e6d9 | ||
|
|
6d7c7b78d9 | ||
|
|
b00ae596ef | ||
|
|
e6299a03e8 | ||
|
|
e77c26c792 | ||
|
|
a426adde65 | ||
|
|
df29586b7d | ||
|
|
d5cd7f9f7e |
@@ -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
@@ -107,3 +107,4 @@ venv.bak/
|
||||
.idea/
|
||||
data/
|
||||
log/
|
||||
.vercel
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
13
README.md
@@ -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以外平台)
|
||||
|
||||
|
||||
21
api/base.py
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
19
api/main.py
@@ -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}),
|
||||
]
|
||||
|
||||
@@ -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': 'oi!oi!oi!你的身份码错误了!别再重试了!!!!!!!!!!',
|
||||
'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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
2
blivedm
78
config.py
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
# 第三方库用CDN引入
|
||||
LIB_USE_CDN=false
|
||||
# production环境生成source map
|
||||
PROD_SOURCE_MAP=true
|
||||
# 动态发现后端endpoint
|
||||
BACKEND_DISCOVERY=false
|
||||
4
frontend/.env.common_server
Normal file
@@ -0,0 +1,4 @@
|
||||
NODE_ENV=production
|
||||
LIB_USE_CDN=true
|
||||
PROD_SOURCE_MAP=false
|
||||
BACKEND_DISCOVERY=true
|
||||
1
frontend/.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
frontend/public/static/img/tutorial/tutorial-1.jpg
Normal file
|
After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
BIN
frontend/public/static/img/tutorial/tutorial-2.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 98 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-3.jpg
Normal file
|
After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 264 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-4.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 14 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-5.jpg
Normal file
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 86 KiB |
171
frontend/src/api/base.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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": "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
|
||||
// 不能用localhost,https://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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ''):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -6,7 +6,7 @@ import aiohttp
|
||||
import utils.async_io
|
||||
import utils.request
|
||||
|
||||
VERSION = 'v1.9.1'
|
||||
VERSION = 'v1.9.3'
|
||||
|
||||
|
||||
def check_update():
|
||||
|
||||
119
utils/request.py
@@ -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
|
||||
|
||||