nonebot-plugin-bililive 2.1.5__tar.gz → 2.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/PKG-INFO +4 -3
  2. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/README.md +2 -1
  3. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/config.py +10 -0
  4. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/database/db.py +55 -14
  5. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/pusher/dynamic_pusher.py +1 -3
  6. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/utils/__init__.py +13 -7
  7. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/utils/browser.py +90 -19
  8. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/version.py +1 -1
  9. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/pyproject.toml +2 -2
  10. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/tests/test_maintenance.py +147 -1
  11. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/LICENSE +0 -0
  12. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/__init__.py +0 -0
  13. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/__main__.py +0 -0
  14. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/bilibili_api.py +0 -0
  15. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/cli/__init__.py +0 -0
  16. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/cli/bot.py +0 -0
  17. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/cli/utils.py +0 -0
  18. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/database/__init__.py +0 -0
  19. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/database/models.py +0 -0
  20. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/libs/__init__.py +0 -0
  21. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/libs/dynamic/__init__.py +0 -0
  22. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/libs/dynamic/card.py +0 -0
  23. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/libs/dynamic/desc.py +0 -0
  24. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/libs/dynamic/display.py +0 -0
  25. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/libs/dynamic/user_profile.py +0 -0
  26. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/libs/dynamic/web.py +0 -0
  27. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/__init__.py +0 -0
  28. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/at/__init__.py +0 -0
  29. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/at/at_off.py +0 -0
  30. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/at/at_on.py +0 -0
  31. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/auto_agree.py +0 -0
  32. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/auto_delete.py +0 -0
  33. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/dynamic/__init__.py +0 -0
  34. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/dynamic/dynamic_off.py +0 -0
  35. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/dynamic/dynamic_on.py +0 -0
  36. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/help.py +0 -0
  37. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/live/__init__.py +0 -0
  38. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/live/live_now.py +0 -0
  39. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/live/live_off.py +0 -0
  40. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/live/live_on.py +0 -0
  41. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/permission/__init__.py +0 -0
  42. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/permission/permission_off.py +0 -0
  43. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/permission/permission_on.py +0 -0
  44. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/pusher/__init__.py +0 -0
  45. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/pusher/live_pusher.py +0 -0
  46. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/sub/__init__.py +0 -0
  47. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/sub/add_sub.py +0 -0
  48. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/sub/delete_sub.py +0 -0
  49. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/plugins/sub/sub_list.py +0 -0
  50. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/utils/captcha_solver.py +0 -0
  51. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/utils/fonts_provider.py +0 -0
  52. {nonebot_plugin_bililive-2.1.5 → nonebot_plugin_bililive-2.1.7}/nonebot_plugin_bililive/utils/mobile.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nonebot-plugin-bililive
3
- Version: 2.1.5
3
+ Version: 2.1.7
4
4
  Summary: Push bilibili dynamics and live notifications to QQ with NoneBot2.
5
5
  Keywords: nonebot,nonebot2,nonebot-plugin,bilibili,onebot,onebot-v11
6
6
  Author-Email: Akiyy_Lab <2806578374@qq.com>
@@ -27,7 +27,7 @@ Requires-Dist: nonebot2>=2.5.0
27
27
  Requires-Dist: playwright>=1.58.0
28
28
  Requires-Dist: pydantic<3.0,>=1.10.0
29
29
  Requires-Dist: python-dotenv>=1.2.2
30
- Requires-Dist: tortoise-orm[asyncpg]>=0.19.3
30
+ Requires-Dist: tortoise-orm[asyncpg]<2.0,>=1.0.0
31
31
  Requires-Dist: packaging>=26.0
32
32
  Requires-Dist: msvc-runtime>=14.34.31931; sys_platform == "win32"
33
33
  Description-Content-Type: text/markdown
@@ -116,8 +116,9 @@ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直
116
116
  | BILILIVE_DYNAMIC_FONT | 否 | Noto Sans CJK SC | 截图字体 |
117
117
  | BILILIVE_DYNAMIC_BIG_IMAGE | 否 | false | 是否优先展示大图 |
118
118
  | BILILIVE_COMMAND_PREFIX | 否 | 空字符串 | 命令额外前缀 |
119
+ | BILILIVE_CHROMIUM_ENDPOINT | 否 | 无 | 外部 Chromium CDP 地址,如 `http://127.0.0.1:9222` |
119
120
 
120
- 动态抓取使用 Playwright 持久化浏览器中的 cookies 请求网页动态接口。通常不需要额外配置 Cookie 登录;如果某些 UID 持续抓取失败,建议在插件使用的浏览器数据目录中登录一个常用的 B 站账号,以提高动态抓取成功率。
121
+ 动态抓取优先使用 `BILILIVE_CHROMIUM_ENDPOINT` 连接的外部 Chromium;未配置时使用 Playwright 持久化浏览器中的 cookies 请求网页动态接口。若某些 UID 持续抓取失败,建议在外部 Chromium 或插件浏览器数据目录中登录常用 B 站账号,以降低风控概率。
121
122
 
122
123
  ## 🎉 使用
123
124
 
@@ -82,8 +82,9 @@ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直
82
82
  | BILILIVE_DYNAMIC_FONT | 否 | Noto Sans CJK SC | 截图字体 |
83
83
  | BILILIVE_DYNAMIC_BIG_IMAGE | 否 | false | 是否优先展示大图 |
84
84
  | BILILIVE_COMMAND_PREFIX | 否 | 空字符串 | 命令额外前缀 |
85
+ | BILILIVE_CHROMIUM_ENDPOINT | 否 | 无 | 外部 Chromium CDP 地址,如 `http://127.0.0.1:9222` |
85
86
 
86
- 动态抓取使用 Playwright 持久化浏览器中的 cookies 请求网页动态接口。通常不需要额外配置 Cookie 登录;如果某些 UID 持续抓取失败,建议在插件使用的浏览器数据目录中登录一个常用的 B 站账号,以提高动态抓取成功率。
87
+ 动态抓取优先使用 `BILILIVE_CHROMIUM_ENDPOINT` 连接的外部 Chromium;未配置时使用 Playwright 持久化浏览器中的 cookies 请求网页动态接口。若某些 UID 持续抓取失败,建议在外部 Chromium 或插件浏览器数据目录中登录常用 B 站账号,以降低风控概率。
87
88
 
88
89
  ## 🎉 使用
89
90
 
@@ -21,6 +21,7 @@ class Config(BaseModel):
21
21
  bililive_captcha_address: str = "https://captcha-cd.ngworks.cn"
22
22
  bililive_captcha_token: str = "bililive"
23
23
  bililive_browser_ua: str | None = None
24
+ bililive_chromium_endpoint: str | None = None
24
25
  bililive_dynamic_timeout: int = 30
25
26
  bililive_dynamic_font_source: str = "system"
26
27
  bililive_dynamic_font: str | None = "Noto Sans CJK SC"
@@ -46,6 +47,7 @@ class Config(BaseModel):
46
47
  "haruka_captcha_address": "bililive_captcha_address",
47
48
  "haruka_captcha_token": "bililive_captcha_token",
48
49
  "haruka_browser_ua": "bililive_browser_ua",
50
+ "haruka_chromium_endpoint": "bililive_chromium_endpoint",
49
51
  "haruka_dynamic_timeout": "bililive_dynamic_timeout",
50
52
  "haruka_dynamic_font_source": "bililive_dynamic_font_source",
51
53
  "haruka_dynamic_font": "bililive_dynamic_font",
@@ -72,6 +74,14 @@ class Config(BaseModel):
72
74
  def dynamic_interval_non_negative(cls, value: int):
73
75
  return 0 if value < 1 else value
74
76
 
77
+ @field_validator("bililive_chromium_endpoint")
78
+ @classmethod
79
+ def chromium_endpoint(cls, value: str | None):
80
+ if value is None:
81
+ return None
82
+ value = value.strip()
83
+ return value or None
84
+
75
85
  @field_validator("bililive_screenshot_style")
76
86
  @classmethod
77
87
  def screenshot_style(cls, value: str):
@@ -1,11 +1,32 @@
1
1
  import asyncio
2
+ import inspect
2
3
  import json
4
+ from collections.abc import Callable
3
5
  from pathlib import Path
4
6
 
5
7
  from nonebot import get_driver, logger
6
8
  from packaging.version import Version as version_parser
7
9
  from tortoise import Tortoise
8
10
 
11
+ get_current_context: Callable[[], object | None] | None
12
+
13
+
14
+ def _detect_tortoise_v1() -> bool:
15
+ """Tortoise 1.x 才有 context 模块与 _enable_global_fallback 参数。"""
16
+ try:
17
+ from tortoise.context import get_current_context as _gcc
18
+ except ImportError:
19
+ return False
20
+
21
+ global get_current_context
22
+ get_current_context = _gcc
23
+ params = inspect.signature(Tortoise.init).parameters
24
+ return "_enable_global_fallback" in params
25
+
26
+
27
+ get_current_context = None
28
+ _TORTOISE_V1 = _detect_tortoise_v1()
29
+
9
30
  from ..utils import get_path
10
31
  from ..version import VERSION as APP_VERSION
11
32
  from .models import Group, Sub, User, Version
@@ -28,9 +49,11 @@ class DB:
28
49
  @classmethod
29
50
  def _orm_context_ok(cls) -> bool:
30
51
  """_ready 与 Tortoise 1.x 全局上下文需同时成立,否则会出现 No TortoiseContext。"""
31
- from tortoise.context import get_current_context
32
-
33
- return cls._ready and get_current_context() is not None
52
+ if not cls._ready:
53
+ return False
54
+ if not _TORTOISE_V1:
55
+ return True
56
+ return get_current_context() is not None
34
57
 
35
58
  @classmethod
36
59
  async def _do_init(cls) -> None:
@@ -48,7 +71,16 @@ class DB:
48
71
  },
49
72
  }
50
73
 
51
- await Tortoise.init(config, _enable_global_fallback=True)
74
+ init_kwargs = (
75
+ {"_enable_global_fallback": True} if _TORTOISE_V1 else {}
76
+ )
77
+ try:
78
+ await Tortoise.init(config, **init_kwargs)
79
+ except TypeError:
80
+ if init_kwargs:
81
+ await Tortoise.init(config)
82
+ else:
83
+ raise
52
84
 
53
85
  await Tortoise.generate_schemas()
54
86
  await cls.migrate()
@@ -73,9 +105,9 @@ class DB:
73
105
  @classmethod
74
106
  async def _recover_stale_orm(cls) -> None:
75
107
  """_ready 仍为 True 但 Tortoise 上下文已丢失时,关闭并重新初始化。"""
108
+ if not _TORTOISE_V1:
109
+ return
76
110
  async with _db_init_lock:
77
- from tortoise.context import get_current_context
78
-
79
111
  if get_current_context() is not None:
80
112
  return
81
113
  if not cls._ready:
@@ -101,13 +133,10 @@ class DB:
101
133
  while waited < timeout:
102
134
  if cls._orm_context_ok():
103
135
  return True
104
- if cls._ready:
105
- from tortoise.context import get_current_context
106
-
107
- if get_current_context() is None:
108
- await cls._recover_stale_orm()
109
- if cls._orm_context_ok():
110
- return True
136
+ if cls._ready and _TORTOISE_V1 and get_current_context() is None:
137
+ await cls._recover_stale_orm()
138
+ if cls._orm_context_ok():
139
+ return True
111
140
  await asyncio.sleep(interval)
112
141
  waited += interval
113
142
 
@@ -275,7 +304,19 @@ class DB:
275
304
  @classmethod
276
305
  async def set_sub(cls, conf, switch, **kwargs):
277
306
  """开关订阅设置"""
278
- return await Sub.update(kwargs, **{conf: switch})
307
+ sub = await cls.get_sub(**kwargs)
308
+ if not sub:
309
+ return False
310
+ previous = getattr(sub, conf, None)
311
+ updated = await Sub.update(kwargs, **{conf: switch})
312
+ if not updated:
313
+ return False
314
+ if conf == "dynamic":
315
+ if switch and not previous:
316
+ dynamic_offset[int(kwargs["uid"])] = -1
317
+ await cls.save_dynamic_offsets()
318
+ await cls.update_uid_list()
319
+ return True
279
320
 
280
321
  @classmethod
281
322
  async def get_version(cls):
@@ -135,9 +135,7 @@ async def process_dynamic_uid(uid: int):
135
135
 
136
136
  dynamic_risk_control_until.pop(uid, None)
137
137
 
138
- if not dynamics: # 没发过动态
139
- if uid in offset and offset[uid] == -1: # 不记录会导致第一次发动态不推送
140
- await db.set_dynamic_offset(uid, 0)
138
+ if not dynamics:
141
139
  return
142
140
  name = get_dynamic_author_name(dynamics[0], use_web_fallback)
143
141
 
@@ -298,14 +298,20 @@ def on_startup():
298
298
  from .browser import check_playwright_env, install
299
299
 
300
300
  check_proxy()
301
- install()
302
- try:
303
- asyncio.get_event_loop().run_until_complete(check_playwright_env())
304
- except ImportError as err:
305
- logger.warning(
306
- "Playwright 运行环境不完整,已跳过启动时强校验;"
307
- f"涉及截图/浏览器能力时可能不可用。错误:{err}"
301
+ if plugin_config.bililive_chromium_endpoint:
302
+ logger.info(
303
+ "已配置 BILILIVE_CHROMIUM_ENDPOINT,"
304
+ "将优先连接外部 Chromium,跳过内置 Chromium 安装"
308
305
  )
306
+ else:
307
+ install()
308
+ try:
309
+ asyncio.get_event_loop().run_until_complete(check_playwright_env())
310
+ except ImportError as err:
311
+ logger.warning(
312
+ "Playwright 运行环境不完整,已跳过启动时强校验;"
313
+ f"涉及截图/浏览器能力时可能不可用。错误:{err}"
314
+ )
309
315
  DATA_DIR.mkdir(parents=True, exist_ok=True)
310
316
 
311
317
 
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
 
8
8
  from nonebot import logger
9
9
  from playwright.__main__ import main
10
- from playwright.async_api import BrowserContext, Page, async_playwright
10
+ from playwright.async_api import BrowserContext, Page, Playwright, async_playwright
11
11
 
12
12
  from ..config import plugin_config
13
13
  from ..utils import get_path
@@ -15,29 +15,81 @@ from .captcha_solver import CaptchaInfer
15
15
  from .fonts_provider import fill_font
16
16
 
17
17
  _browser: BrowserContext | None = None
18
+ _playwright: Playwright | None = None
18
19
  mobile_js = Path(__file__).parent.joinpath("mobile.js")
19
20
  WEB_DYNAMIC_URL = "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space"
21
+ DEFAULT_MOBILE_USER_AGENT = (
22
+ "Mozilla/5.0 (Linux; Android 10; RMX1911) AppleWebKit/537.36 "
23
+ "(KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
24
+ )
25
+ DEFAULT_DESKTOP_USER_AGENT = (
26
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
27
+ "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
28
+ )
29
+
30
+
31
+ def get_user_agent() -> str:
32
+ if plugin_config.bililive_browser_ua:
33
+ return plugin_config.bililive_browser_ua
34
+ if plugin_config.bililive_screenshot_style.lower() == "mobile":
35
+ return DEFAULT_MOBILE_USER_AGENT
36
+ return DEFAULT_DESKTOP_USER_AGENT
37
+
38
+
39
+ def get_dynamic_api_headers(uid: int) -> dict[str, str]:
40
+ return {
41
+ "accept": "application/json, text/plain, */*",
42
+ "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
43
+ "referer": f"https://space.bilibili.com/{uid}/dynamic",
44
+ "origin": "https://space.bilibili.com",
45
+ }
46
+
47
+
48
+ async def _ensure_playwright() -> Playwright:
49
+ global _playwright
50
+ if _playwright is None:
51
+ _playwright = await async_playwright().start()
52
+ return _playwright
53
+
54
+
55
+ async def _apply_browser_headers(context: BrowserContext) -> None:
56
+ await context.set_extra_http_headers(
57
+ {
58
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
59
+ }
60
+ )
20
61
 
21
62
 
22
- async def init_browser(proxy=plugin_config.bililive_proxy, **kwargs) -> BrowserContext:
23
- logger.info("初始化浏览器")
63
+ async def init_browser_cdp(endpoint: str) -> BrowserContext:
64
+ logger.info(f"连接外部 Chromium:{endpoint}")
65
+ global _browser
66
+ playwright = await _ensure_playwright()
67
+ browser = await playwright.chromium.connect_over_cdp(endpoint)
68
+ if browser.contexts:
69
+ browser_context = browser.contexts[0]
70
+ else:
71
+ browser_context = await browser.new_context(
72
+ user_agent=get_user_agent(),
73
+ device_scale_factor=2,
74
+ )
75
+ await _apply_browser_headers(browser_context)
76
+ _browser = browser_context
77
+ return _browser
78
+
79
+
80
+ async def init_browser_playwright(
81
+ proxy=plugin_config.bililive_proxy, **kwargs
82
+ ) -> BrowserContext:
83
+ logger.info("初始化 Playwright 内置浏览器")
24
84
  if proxy:
25
85
  kwargs["proxy"] = {"server": proxy}
26
86
  global _browser
27
- p = await async_playwright().start()
87
+ playwright = await _ensure_playwright()
28
88
  browser_data = Path(get_path("browser"))
29
89
  browser_data.mkdir(parents=True, exist_ok=True)
30
- browser_context = await p.chromium.launch_persistent_context(
90
+ browser_context = await playwright.chromium.launch_persistent_context(
31
91
  browser_data,
32
- user_agent=plugin_config.bililive_browser_ua
33
- or (
34
- (
35
- "Mozilla/5.0 (Linux; Android 10; RMX1911) AppleWebKit/537.36 "
36
- "(KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
37
- )
38
- if plugin_config.bililive_screenshot_style.lower() == "mobile"
39
- else None
40
- ),
92
+ user_agent=get_user_agent(),
41
93
  device_scale_factor=2,
42
94
  timeout=plugin_config.bililive_dynamic_timeout * 1000,
43
95
  **kwargs,
@@ -53,10 +105,24 @@ async def init_browser(proxy=plugin_config.bililive_proxy, **kwargs) -> BrowserC
53
105
  }
54
106
  ]
55
107
  )
108
+ await _apply_browser_headers(browser_context)
56
109
  _browser = browser_context
57
110
  return _browser
58
111
 
59
112
 
113
+ async def init_browser(proxy=plugin_config.bililive_proxy, **kwargs) -> BrowserContext:
114
+ endpoint = plugin_config.bililive_chromium_endpoint
115
+ if endpoint:
116
+ try:
117
+ return await init_browser_cdp(endpoint)
118
+ except Exception as err:
119
+ logger.warning(
120
+ f"连接外部 Chromium 失败({endpoint}),"
121
+ f"将回退到 Playwright 内置浏览器:{err}"
122
+ )
123
+ return await init_browser_playwright(proxy=proxy, **kwargs)
124
+
125
+
60
126
  async def get_browser() -> BrowserContext:
61
127
  global _browser
62
128
  if not _browser:
@@ -76,23 +142,28 @@ async def get_bilibili_cookies() -> dict[str, str]:
76
142
  async def get_user_dynamics_payload_in_browser(uid: int) -> dict:
77
143
  browser = await get_browser()
78
144
  page = await browser.new_page()
145
+ api_headers = get_dynamic_api_headers(uid)
79
146
  try:
147
+ await page.set_extra_http_headers(
148
+ {
149
+ **api_headers,
150
+ "User-Agent": get_user_agent(),
151
+ }
152
+ )
80
153
  await page.goto(
81
154
  "https://www.bilibili.com/",
82
155
  wait_until="domcontentloaded",
83
156
  timeout=plugin_config.bililive_dynamic_timeout * 1000,
84
157
  )
85
158
  return await page.evaluate(
86
- """async ({ url, uid }) => {
159
+ """async ({ url, uid, headers }) => {
87
160
  const response = await fetch(`${url}?host_mid=${uid}`, {
88
161
  credentials: 'include',
89
- headers: {
90
- accept: 'application/json, text/plain, */*',
91
- },
162
+ headers,
92
163
  });
93
164
  return await response.json();
94
165
  }""",
95
- {"url": WEB_DYNAMIC_URL, "uid": str(uid)},
166
+ {"url": WEB_DYNAMIC_URL, "uid": str(uid), "headers": api_headers},
96
167
  )
97
168
  finally:
98
169
  with contextlib.suppress(Exception):
@@ -1,4 +1,4 @@
1
1
  from packaging.version import Version
2
2
 
3
- __version__ = "2.1.5"
3
+ __version__ = "2.1.7"
4
4
  VERSION = Version(__version__)
@@ -34,12 +34,12 @@ dependencies = [
34
34
  "playwright>=1.58.0",
35
35
  "pydantic>=1.10.0,<3.0",
36
36
  "python-dotenv>=1.2.2",
37
- "tortoise-orm[asyncpg]>=0.19.3",
37
+ "tortoise-orm[asyncpg]>=1.0.0,<2.0",
38
38
  "packaging>=26.0",
39
39
  "msvc-runtime>=14.34.31931; sys_platform == \"win32\"",
40
40
  ]
41
41
  dynamic = []
42
- version = "2.1.5"
42
+ version = "2.1.7"
43
43
 
44
44
  [project.license]
45
45
  text = "AGPL-3.0-or-later"
@@ -65,10 +65,12 @@ with patch("nonebot.get_driver", return_value=DummyDriver()), patch(
65
65
  core_version = import_module("nonebot_plugin_bililive.version")
66
66
  db_module = import_module("nonebot_plugin_bililive.database.db")
67
67
  web_dynamic = import_module("nonebot_plugin_bililive.libs.dynamic.web")
68
+ browser_module = import_module("nonebot_plugin_bililive.utils.browser")
68
69
  plugin_entry = import_module("nonebot_plugin_bililive")
69
70
  DB = db_module.DB
70
71
  models = import_module("nonebot_plugin_bililive.database.models")
71
72
  Group = models.Group
73
+ Sub = models.Sub
72
74
  get_path = import_module("nonebot_plugin_bililive.utils").get_path
73
75
 
74
76
 
@@ -102,6 +104,16 @@ class ConfigTests(unittest.TestCase):
102
104
  self.assertEqual(config.bililive_live_interval, 12)
103
105
  self.assertEqual(config.bililive_command_prefix, "hb")
104
106
 
107
+ def test_chromium_endpoint_is_trimmed(self):
108
+ config = Config(bililive_chromium_endpoint=" http://127.0.0.1:9222 ")
109
+
110
+ self.assertEqual(config.bililive_chromium_endpoint, "http://127.0.0.1:9222")
111
+
112
+ def test_legacy_haruka_chromium_endpoint_is_migrated(self):
113
+ config = Config(**{"haruka_chromium_endpoint": "http://127.0.0.1:9333"})
114
+
115
+ self.assertEqual(config.bililive_chromium_endpoint, "http://127.0.0.1:9333")
116
+
105
117
 
106
118
  class PluginEntryTests(unittest.TestCase):
107
119
  def test_plugin_entry_exposes_plugin_metadata(self):
@@ -131,6 +143,67 @@ class PluginEntryTests(unittest.TestCase):
131
143
  self.assertNotIn('bilireq>=', pyproject)
132
144
 
133
145
 
146
+ class BrowserHelperTests(unittest.IsolatedAsyncioTestCase):
147
+ def test_get_dynamic_api_headers_include_space_referer(self):
148
+ headers = browser_module.get_dynamic_api_headers(477332594)
149
+
150
+ self.assertEqual(
151
+ headers["referer"],
152
+ "https://space.bilibili.com/477332594/dynamic",
153
+ )
154
+ self.assertEqual(headers["origin"], "https://space.bilibili.com")
155
+ self.assertIn("application/json", headers["accept"])
156
+
157
+ async def test_init_browser_prefers_external_chromium_when_configured(self):
158
+ cdp_context = object()
159
+ with (
160
+ patch.object(
161
+ browser_module.plugin_config,
162
+ "bililive_chromium_endpoint",
163
+ "http://127.0.0.1:9222",
164
+ ),
165
+ patch.object(
166
+ browser_module,
167
+ "init_browser_cdp",
168
+ new=AsyncMock(return_value=cdp_context),
169
+ ) as init_browser_cdp,
170
+ patch.object(
171
+ browser_module,
172
+ "init_browser_playwright",
173
+ new=AsyncMock(),
174
+ ) as init_browser_playwright,
175
+ ):
176
+ context = await browser_module.init_browser()
177
+
178
+ self.assertIs(context, cdp_context)
179
+ init_browser_cdp.assert_awaited_once_with("http://127.0.0.1:9222")
180
+ init_browser_playwright.assert_not_awaited()
181
+
182
+ async def test_init_browser_falls_back_to_playwright_when_cdp_fails(self):
183
+ playwright_context = object()
184
+ with (
185
+ patch.object(
186
+ browser_module.plugin_config,
187
+ "bililive_chromium_endpoint",
188
+ "http://127.0.0.1:9222",
189
+ ),
190
+ patch.object(
191
+ browser_module,
192
+ "init_browser_cdp",
193
+ new=AsyncMock(side_effect=RuntimeError("connect failed")),
194
+ ),
195
+ patch.object(
196
+ browser_module,
197
+ "init_browser_playwright",
198
+ new=AsyncMock(return_value=playwright_context),
199
+ ) as init_browser_playwright,
200
+ ):
201
+ context = await browser_module.init_browser()
202
+
203
+ self.assertIs(context, playwright_context)
204
+ init_browser_playwright.assert_awaited_once()
205
+
206
+
134
207
  class WebDynamicTests(unittest.TestCase):
135
208
  def test_parse_web_dynamic_items_extracts_required_fields(self):
136
209
  payload = {
@@ -197,7 +270,14 @@ class DBPermissionTests(unittest.IsolatedAsyncioTestCase):
197
270
  ):
198
271
  await DB.init()
199
272
 
200
- self.assertTrue(init_db.await_args.kwargs["_enable_global_fallback"])
273
+ if db_module._TORTOISE_V1:
274
+ self.assertTrue(
275
+ init_db.await_args.kwargs["_enable_global_fallback"]
276
+ )
277
+ else:
278
+ self.assertNotIn(
279
+ "_enable_global_fallback", init_db.await_args.kwargs
280
+ )
201
281
  self.assertTrue(DB._ready)
202
282
  generate_schemas.assert_awaited_once()
203
283
  migrate.assert_awaited_once()
@@ -261,6 +341,72 @@ class DBPermissionTests(unittest.IsolatedAsyncioTestCase):
261
341
  self.assertFalse(changed)
262
342
  update.assert_not_awaited()
263
343
 
344
+ async def test_set_sub_enabling_dynamic_resets_offset_and_updates_uid_list(self):
345
+ sub = SimpleNamespace(dynamic=False)
346
+ db_module.dynamic_offset.clear()
347
+ db_module.dynamic_offset[123] = 456
348
+ with (
349
+ patch.object(DB, "get_sub", new=AsyncMock(return_value=sub)),
350
+ patch.object(Sub, "update", new=AsyncMock(return_value=True)),
351
+ patch.object(DB, "update_uid_list", new=AsyncMock()) as update_uid_list,
352
+ patch.object(DB, "save_dynamic_offsets", new=AsyncMock()) as save_offsets,
353
+ ):
354
+ updated = await DB.set_sub(
355
+ "dynamic",
356
+ True,
357
+ uid=123,
358
+ type="group",
359
+ type_id=456,
360
+ )
361
+
362
+ self.assertTrue(updated)
363
+ self.assertEqual(db_module.dynamic_offset[123], -1)
364
+ save_offsets.assert_awaited_once()
365
+ update_uid_list.assert_awaited_once()
366
+
367
+ async def test_set_sub_enabling_dynamic_skips_reset_when_already_enabled(self):
368
+ sub = SimpleNamespace(dynamic=True)
369
+ db_module.dynamic_offset.clear()
370
+ db_module.dynamic_offset[123] = 456
371
+ with (
372
+ patch.object(DB, "get_sub", new=AsyncMock(return_value=sub)),
373
+ patch.object(Sub, "update", new=AsyncMock(return_value=True)),
374
+ patch.object(DB, "update_uid_list", new=AsyncMock()) as update_uid_list,
375
+ patch.object(DB, "save_dynamic_offsets", new=AsyncMock()) as save_offsets,
376
+ ):
377
+ updated = await DB.set_sub(
378
+ "dynamic",
379
+ True,
380
+ uid=123,
381
+ type="group",
382
+ type_id=456,
383
+ )
384
+
385
+ self.assertTrue(updated)
386
+ self.assertEqual(db_module.dynamic_offset[123], 456)
387
+ save_offsets.assert_not_awaited()
388
+ update_uid_list.assert_awaited_once()
389
+
390
+ async def test_set_sub_disabling_dynamic_updates_uid_list(self):
391
+ sub = SimpleNamespace(dynamic=True)
392
+ with (
393
+ patch.object(DB, "get_sub", new=AsyncMock(return_value=sub)),
394
+ patch.object(Sub, "update", new=AsyncMock(return_value=True)),
395
+ patch.object(DB, "update_uid_list", new=AsyncMock()) as update_uid_list,
396
+ patch.object(DB, "save_dynamic_offsets", new=AsyncMock()) as save_offsets,
397
+ ):
398
+ updated = await DB.set_sub(
399
+ "dynamic",
400
+ False,
401
+ uid=123,
402
+ type="group",
403
+ type_id=456,
404
+ )
405
+
406
+ self.assertTrue(updated)
407
+ save_offsets.assert_not_awaited()
408
+ update_uid_list.assert_awaited_once()
409
+
264
410
 
265
411
  if __name__ == "__main__":
266
412
  unittest.main()