mirror of
https://github.com/xfgryujk/blivechat.git
synced 2026-08-19 09:43:28 +08:00
添加登录插件
This commit is contained in:
21
plugins/login/LICENSE
Normal file
21
plugins/login/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 xfgryujk
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
176
plugins/login/app.py
Normal file
176
plugins/login/app.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import http.cookies
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
import webview
|
||||
|
||||
import blcsdk
|
||||
import config
|
||||
import cookies_mgr
|
||||
import listener
|
||||
|
||||
logger = logging.getLogger('login.' + __name__)
|
||||
|
||||
_app: Optional['App'] = None
|
||||
|
||||
|
||||
def init():
|
||||
global _app
|
||||
_app = App()
|
||||
_app.init()
|
||||
|
||||
|
||||
def get_app():
|
||||
return _app
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self):
|
||||
self._is_standalone_mode = False
|
||||
|
||||
self._logic_worker = LogicWorker()
|
||||
|
||||
self._browser_window = webview.create_window(
|
||||
title='登录B站账号',
|
||||
width=1100,
|
||||
height=700,
|
||||
hidden=True,
|
||||
)
|
||||
self._browser_window.events.closing += self._on_browser_closing
|
||||
self._browser_window.events.loaded += self._on_browser_loaded
|
||||
|
||||
def init(self):
|
||||
self._logic_worker.init()
|
||||
|
||||
@staticmethod
|
||||
def run():
|
||||
logger.info('Running event loop')
|
||||
webview.start(user_agent=config.USER_AGENT)
|
||||
logger.info('Start to shut down')
|
||||
|
||||
def start_shut_down(self):
|
||||
self._browser_window.events.loaded -= self._on_browser_loaded
|
||||
self._browser_window.events.closing -= self._on_browser_closing
|
||||
self._browser_window.destroy()
|
||||
|
||||
def shut_down(self):
|
||||
self._logic_worker.start_shut_down()
|
||||
self._logic_worker.join(10)
|
||||
|
||||
@property
|
||||
def is_standalone_mode(self):
|
||||
"""不作为blivechat插件运行,而是单独运行"""
|
||||
return self._is_standalone_mode
|
||||
|
||||
def set_standalone_mode(self):
|
||||
self._is_standalone_mode = True
|
||||
|
||||
def open_admin_ui(self):
|
||||
self._browser_window.clear_cookies()
|
||||
# 如果不换个URL,不会发loaded事件,会一直等待...
|
||||
url = f'https://passport.bilibili.com/login?t={time.time()}'
|
||||
self._browser_window.load_url(url)
|
||||
self._browser_window.show()
|
||||
|
||||
def _on_browser_closing(self):
|
||||
if self.is_standalone_mode:
|
||||
return True
|
||||
|
||||
# 插件模式时随主程序一起结束,先不关闭窗口
|
||||
self._browser_window.hide()
|
||||
self._browser_window.load_html('')
|
||||
return False
|
||||
|
||||
def _on_browser_loaded(self):
|
||||
simple_cookies: List[http.cookies.SimpleCookie] = self._browser_window.get_cookies()
|
||||
async def try_save():
|
||||
cookie_jar = aiohttp.CookieJar()
|
||||
for simple_cookie in simple_cookies:
|
||||
cookie_jar.update_cookies(simple_cookie)
|
||||
|
||||
if not await cookies_mgr.validate_and_save_cookies(cookie_jar):
|
||||
return
|
||||
|
||||
if self.is_standalone_mode:
|
||||
self.start_shut_down()
|
||||
else:
|
||||
self._browser_window.hide()
|
||||
self._browser_window.load_html('')
|
||||
|
||||
self._logic_worker.run_coro(try_save())
|
||||
|
||||
|
||||
class LogicWorker:
|
||||
def __init__(self):
|
||||
self._worker_thread = threading.Thread(
|
||||
target=asyncio.run, args=(self._worker_thread_func(),), daemon=True
|
||||
)
|
||||
self._thread_init_future = concurrent.futures.Future()
|
||||
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._shut_down_event: Optional[asyncio.Event] = None
|
||||
|
||||
def init(self):
|
||||
self._worker_thread.start()
|
||||
self._thread_init_future.result(10)
|
||||
|
||||
def start_shut_down(self):
|
||||
if self._shut_down_event is not None:
|
||||
self._loop.call_soon_threadsafe(self._shut_down_event.set)
|
||||
|
||||
def join(self, timeout=None):
|
||||
self._worker_thread.join(timeout)
|
||||
return not self._worker_thread.is_alive()
|
||||
|
||||
async def _worker_thread_func(self):
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._shut_down_event = asyncio.Event()
|
||||
try:
|
||||
try:
|
||||
await self._init_in_worker_thread()
|
||||
self._thread_init_future.set_result(None)
|
||||
except BaseException as e:
|
||||
self._thread_init_future.set_exception(e)
|
||||
return
|
||||
|
||||
await self._run()
|
||||
finally:
|
||||
await self._shut_down()
|
||||
|
||||
@staticmethod
|
||||
async def _init_in_worker_thread():
|
||||
try:
|
||||
await blcsdk.init()
|
||||
except blcsdk.InitError as e:
|
||||
logger.info('SDK initializing failed: %s', e)
|
||||
logger.info('Switch to standalone mode')
|
||||
get_app().set_standalone_mode()
|
||||
else:
|
||||
await listener.init()
|
||||
|
||||
cookies_mgr.init()
|
||||
|
||||
async def _run(self):
|
||||
logger.info('Running logic thread event loop')
|
||||
await self._shut_down_event.wait()
|
||||
logger.info('Logic thread start to shut down')
|
||||
|
||||
@staticmethod
|
||||
async def _shut_down():
|
||||
await cookies_mgr.shut_down()
|
||||
|
||||
if not get_app().is_standalone_mode:
|
||||
listener.shut_down()
|
||||
await blcsdk.shut_down()
|
||||
|
||||
# def call_soon(self, func, *args):
|
||||
# self._loop.call_soon_threadsafe(func, *args)
|
||||
|
||||
def run_coro(self, coro: Coroutine):
|
||||
self._loop.call_soon_threadsafe(self._loop.create_task, coro)
|
||||
9
plugins/login/config.py
Normal file
9
plugins/login/config.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
|
||||
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
|
||||
LOG_PATH = os.path.join(BASE_PATH, 'log')
|
||||
|
||||
USER_AGENT = (
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36'
|
||||
)
|
||||
156
plugins/login/cookies_mgr.py
Normal file
156
plugins/login/cookies_mgr.py
Normal file
@@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import sys
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
import yarl
|
||||
|
||||
import app
|
||||
import config
|
||||
|
||||
logger = logging.getLogger('login.' + __name__)
|
||||
|
||||
_BILIBILI_DOMAIN = 'bilibili.com'
|
||||
_USER_INFO_URL = 'https://api.bilibili.com/x/web-interface/nav'
|
||||
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
# 被PyInstaller打包了,用当前目录计算
|
||||
_BLC_COOKIE_JAR_PATH = os.path.realpath(os.path.join('..', '..', 'cookie_jar.pickle'))
|
||||
else:
|
||||
_BLC_COOKIE_JAR_PATH = os.path.realpath(os.path.join(config.BASE_PATH, '..', '..', 'data', 'cookie_jar.pickle'))
|
||||
|
||||
_logic_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_http_session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
# 用来防重
|
||||
_validating_sessdata_set: Set[str] = set()
|
||||
|
||||
|
||||
def init():
|
||||
global _logic_loop, _http_session
|
||||
_logic_loop = asyncio.get_running_loop()
|
||||
_http_session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
)
|
||||
|
||||
_logic_loop.create_task(_validate_on_startup())
|
||||
|
||||
|
||||
async def shut_down():
|
||||
if _http_session is not None:
|
||||
await _http_session.close()
|
||||
|
||||
|
||||
async def _validate_on_startup():
|
||||
"""如果未登录则打开登录窗口"""
|
||||
# 等主线程初始化
|
||||
sleep_task = asyncio.create_task(asyncio.sleep(2))
|
||||
|
||||
app_ = app.get_app()
|
||||
if app_.is_standalone_mode:
|
||||
await sleep_task
|
||||
app_.open_admin_ui()
|
||||
return
|
||||
|
||||
cookie_jar = _load_blc_cookies()
|
||||
if cookie_jar is not None:
|
||||
user_info = await _get_user_info_from_cookies(cookie_jar)
|
||||
if user_info is not None:
|
||||
logger.info('Found logged in user: uid=%d, name=%s', user_info.uid, user_info.name)
|
||||
return
|
||||
|
||||
logger.info('No user info. Please log in')
|
||||
await sleep_task
|
||||
app_.open_admin_ui()
|
||||
|
||||
|
||||
def _load_blc_cookies():
|
||||
try:
|
||||
cookie_jar = aiohttp.CookieJar()
|
||||
cookie_jar.load(_BLC_COOKIE_JAR_PATH)
|
||||
return cookie_jar
|
||||
except (OSError, pickle.PickleError):
|
||||
return None
|
||||
|
||||
|
||||
def _save_blc_cookies(cookie_jar: aiohttp.CookieJar):
|
||||
logger.info('Saving cookies')
|
||||
|
||||
tmp_path = _BLC_COOKIE_JAR_PATH + '.tmp'
|
||||
cookie_jar.save(tmp_path)
|
||||
os.replace(tmp_path, _BLC_COOKIE_JAR_PATH)
|
||||
|
||||
logger.info('Cookies saved. Please restart blivechat to take effect')
|
||||
|
||||
|
||||
async def validate_and_save_cookies(cookie_jar: aiohttp.CookieJar):
|
||||
sessdata = _get_sessdata(cookie_jar)
|
||||
if sessdata is None or sessdata in _validating_sessdata_set:
|
||||
return False
|
||||
_validating_sessdata_set.add(sessdata)
|
||||
|
||||
try:
|
||||
user_info = await _get_user_info_from_cookies(cookie_jar)
|
||||
if user_info is None:
|
||||
return False
|
||||
logger.info('Got user info: uid=%d, name=%s', user_info.uid, user_info.name)
|
||||
|
||||
_save_blc_cookies(cookie_jar)
|
||||
return True
|
||||
except:
|
||||
_validating_sessdata_set.discard(sessdata)
|
||||
raise
|
||||
|
||||
|
||||
def _get_sessdata(cookie_jar: aiohttp.CookieJar):
|
||||
cookies = cookie_jar.filter_cookies(yarl.URL(_USER_INFO_URL))
|
||||
sessdata_cookie = cookies.get('SESSDATA', None)
|
||||
if sessdata_cookie is None or sessdata_cookie.value == '':
|
||||
return None
|
||||
return sessdata_cookie.value
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class BilibiliUserInfo:
|
||||
uid: int
|
||||
name: str
|
||||
|
||||
|
||||
async def _get_user_info_from_cookies(cookie_jar: aiohttp.CookieJar):
|
||||
if _get_sessdata(cookie_jar) is None:
|
||||
logger.info('Failed to get user info: no SESSDATA')
|
||||
return None
|
||||
|
||||
try:
|
||||
async with _http_session.get(
|
||||
_USER_INFO_URL,
|
||||
cookies=cookie_jar.filter_cookies(yarl.URL(_USER_INFO_URL)),
|
||||
headers={'User-Agent': config.USER_AGENT},
|
||||
) as res:
|
||||
res.raise_for_status()
|
||||
|
||||
data = await res.json()
|
||||
if data['code'] != 0:
|
||||
if data['code'] == -101:
|
||||
logger.info('Failed to get user info: not logged in')
|
||||
else:
|
||||
logger.warning('Failed to get user info: code=%d, message=%s', data['code'], data['message'])
|
||||
return None
|
||||
|
||||
data = data['data']
|
||||
if not data['isLogin']:
|
||||
logger.info('Failed to get user info: not logged in')
|
||||
return None
|
||||
|
||||
return BilibiliUserInfo(
|
||||
uid=data['mid'],
|
||||
name=data['uname'],
|
||||
)
|
||||
except aiohttp.ClientError as e:
|
||||
logger.warning('Failed to get user info: %s', e)
|
||||
return None
|
||||
32
plugins/login/listener.py
Normal file
32
plugins/login/listener.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from typing import *
|
||||
|
||||
import app
|
||||
import blcsdk
|
||||
import blcsdk.models as sdk_models
|
||||
|
||||
logger = logging.getLogger('login.' + __name__)
|
||||
|
||||
_msg_handler: Optional['MsgHandler'] = None
|
||||
|
||||
|
||||
async def init():
|
||||
global _msg_handler
|
||||
_msg_handler = MsgHandler()
|
||||
blcsdk.set_msg_handler(_msg_handler)
|
||||
|
||||
|
||||
def shut_down():
|
||||
blcsdk.set_msg_handler(None)
|
||||
|
||||
|
||||
class MsgHandler(blcsdk.BaseHandler):
|
||||
def on_client_stopped(self, client: blcsdk.BlcPluginClient, exception: Optional[Exception]):
|
||||
logger.info('blivechat disconnected')
|
||||
app.get_app().start_shut_down()
|
||||
|
||||
def _on_open_plugin_admin_ui(
|
||||
self, client: blcsdk.BlcPluginClient, message: sdk_models.OpenPluginAdminUiMsg, extra: sdk_models.ExtraData
|
||||
):
|
||||
app.get_app().open_admin_ui()
|
||||
0
plugins/login/log/.gitkeep
Normal file
0
plugins/login/log/.gitkeep
Normal file
89
plugins/login/login.spec
Normal file
89
plugins/login/login.spec
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
import typing
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import webview.__pyinstaller
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import os
|
||||
|
||||
from PyInstaller.building.api import COLLECT, EXE, PYZ
|
||||
from PyInstaller.building.build_main import Analysis
|
||||
|
||||
SPECPATH = ''
|
||||
DISTPATH = ''
|
||||
|
||||
|
||||
# exe文件名、打包目录名
|
||||
NAME = 'login'
|
||||
# 模块搜索路径
|
||||
PYTHONPATH = [
|
||||
os.path.join(SPECPATH, '..', '..'), # 为了找到blcsdk
|
||||
]
|
||||
# 数据
|
||||
DATAS = [
|
||||
('plugin.json', '.'),
|
||||
('LICENSE', '.'),
|
||||
('log/.gitkeep', 'log'),
|
||||
]
|
||||
|
||||
block_cipher = None
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=PYTHONPATH,
|
||||
binaries=[],
|
||||
datas=DATAS,
|
||||
hiddenimports=[],
|
||||
hookspath=[
|
||||
os.path.dirname(webview.__pyinstaller.__file__), # pyinstaller-hooks-contrib的版本太老了,少打包了js文件...
|
||||
],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(
|
||||
a.pure,
|
||||
a.zipped_data,
|
||||
cipher=block_cipher,
|
||||
)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name=NAME,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name=NAME,
|
||||
)
|
||||
|
||||
# 打包
|
||||
print('Start to package')
|
||||
subprocess.run([sys.executable, '-m', 'zipfile', '-c', NAME + '.zip', NAME], cwd=DISTPATH)
|
||||
68
plugins/login/main.py
Normal file
68
plugins/login/main.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging.handlers
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
import app
|
||||
import config
|
||||
|
||||
logger = logging.getLogger('login')
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
init()
|
||||
run()
|
||||
finally:
|
||||
shut_down()
|
||||
return 0
|
||||
|
||||
|
||||
def init():
|
||||
init_signal_handlers()
|
||||
|
||||
init_logging()
|
||||
|
||||
app.init()
|
||||
|
||||
|
||||
def init_signal_handlers():
|
||||
for signum in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(signum, start_shut_down)
|
||||
|
||||
|
||||
def start_shut_down(*_args):
|
||||
app_ = app.get_app()
|
||||
if app_ is not None:
|
||||
app_.start_shut_down()
|
||||
|
||||
|
||||
def init_logging():
|
||||
filename = os.path.join(config.LOG_PATH, 'login.log')
|
||||
stream_handler = logging.StreamHandler()
|
||||
file_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
|
||||
)
|
||||
logging.basicConfig(
|
||||
format='{asctime} {levelname} [{name}]: {message}',
|
||||
style='{',
|
||||
level=logging.INFO,
|
||||
# level=logging.DEBUG,
|
||||
handlers=[stream_handler, file_handler],
|
||||
)
|
||||
|
||||
|
||||
def run():
|
||||
app.get_app().run()
|
||||
|
||||
|
||||
def shut_down():
|
||||
app_ = app.get_app()
|
||||
if app_ is not None:
|
||||
app_.shut_down()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
8
plugins/login/plugin.json
Normal file
8
plugins/login/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "登录B站账号",
|
||||
"version": "1.0.0",
|
||||
"author": "xfgryujk",
|
||||
"description": "启动后会弹出登录账号的窗口,登录后重启blivechat即是登录状态。登录后即使通过房间ID连接,也可以获取昵称。主要用于连不上公共服务器时的应急措施,平时还是尽量通过身份码连接。需要 1. blivechat版本1.10以上;2. 开启“通过服务器转发消息”选项。**建议用小号登录,因为如果连接太多房间,有被B站封号的风险。**cookie完全存储在本地,不会上传到任何服务器。发布地址:https://github.com/xfgryujk/blivechat/discussions/276",
|
||||
"run": "login.exe",
|
||||
"enabled": true
|
||||
}
|
||||
3
plugins/login/requirements.txt
Normal file
3
plugins/login/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
aiohttp==3.9.5
|
||||
pywebview==6.1
|
||||
yarl==1.9.11
|
||||
Reference in New Issue
Block a user