添加登录插件

This commit is contained in:
John Smith
2025-12-08 01:15:50 +08:00
parent 0cc0090869
commit a435bc954b
10 changed files with 562 additions and 0 deletions

21
plugins/login/LICENSE Normal file
View 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
View 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
View 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'
)

View 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
View 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()

View File

89
plugins/login/login.spec Normal file
View 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
View 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())

View 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
}

View File

@@ -0,0 +1,3 @@
aiohttp==3.9.5
pywebview==6.1
yarl==1.9.11