nonebot-plugin-bililive 2.1.6__tar.gz → 2.1.8__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.6 → nonebot_plugin_bililive-2.1.8}/PKG-INFO +3 -2
  2. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/README.md +2 -1
  3. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/config.py +10 -0
  4. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/database/db.py +13 -1
  5. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/pusher/dynamic_pusher.py +34 -31
  6. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/utils/__init__.py +13 -7
  7. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/utils/browser.py +90 -19
  8. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/version.py +1 -1
  9. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/pyproject.toml +1 -1
  10. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/tests/test_maintenance.py +139 -0
  11. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/LICENSE +0 -0
  12. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/__init__.py +0 -0
  13. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/__main__.py +0 -0
  14. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/bilibili_api.py +0 -0
  15. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/cli/__init__.py +0 -0
  16. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/cli/bot.py +0 -0
  17. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/cli/utils.py +0 -0
  18. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/database/__init__.py +0 -0
  19. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/database/models.py +0 -0
  20. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/libs/__init__.py +0 -0
  21. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/libs/dynamic/__init__.py +0 -0
  22. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/libs/dynamic/card.py +0 -0
  23. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/libs/dynamic/desc.py +0 -0
  24. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/libs/dynamic/display.py +0 -0
  25. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/libs/dynamic/user_profile.py +0 -0
  26. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/libs/dynamic/web.py +0 -0
  27. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/__init__.py +0 -0
  28. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/at/__init__.py +0 -0
  29. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/at/at_off.py +0 -0
  30. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/at/at_on.py +0 -0
  31. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/auto_agree.py +0 -0
  32. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/auto_delete.py +0 -0
  33. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/dynamic/__init__.py +0 -0
  34. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/dynamic/dynamic_off.py +0 -0
  35. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/dynamic/dynamic_on.py +0 -0
  36. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/help.py +0 -0
  37. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/live/__init__.py +0 -0
  38. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/live/live_now.py +0 -0
  39. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/live/live_off.py +0 -0
  40. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/live/live_on.py +0 -0
  41. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/permission/__init__.py +0 -0
  42. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/permission/permission_off.py +0 -0
  43. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/permission/permission_on.py +0 -0
  44. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/pusher/__init__.py +0 -0
  45. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/pusher/live_pusher.py +0 -0
  46. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/sub/__init__.py +0 -0
  47. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/sub/add_sub.py +0 -0
  48. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/sub/delete_sub.py +0 -0
  49. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/plugins/sub/sub_list.py +0 -0
  50. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/utils/captcha_solver.py +0 -0
  51. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/nonebot_plugin_bililive/utils/fonts_provider.py +0 -0
  52. {nonebot_plugin_bililive-2.1.6 → nonebot_plugin_bililive-2.1.8}/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.6
3
+ Version: 2.1.8
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>
@@ -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):
@@ -304,7 +304,19 @@ class DB:
304
304
  @classmethod
305
305
  async def set_sub(cls, conf, switch, **kwargs):
306
306
  """开关订阅设置"""
307
- 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
308
320
 
309
321
  @classmethod
310
322
  async def get_version(cls):
@@ -1,5 +1,5 @@
1
1
  import asyncio
2
- from datetime import datetime
2
+ from datetime import datetime, timedelta
3
3
  from time import monotonic
4
4
 
5
5
  from apscheduler.events import (
@@ -45,11 +45,7 @@ WEB_DYNAMIC_TYPE_MESSAGES = {
45
45
  "DYNAMIC_TYPE_MUSIC": "发布了新音频",
46
46
  }
47
47
  DYNAMIC_FETCH_CONCURRENCY = 4
48
-
49
-
50
- async def throttle_dynamic_loop():
51
- if plugin_config.bililive_dynamic_interval == 0:
52
- await asyncio.sleep(1)
48
+ DYNAMIC_THROTTLE_SECONDS = 1
53
49
 
54
50
 
55
51
  def get_dynamic_id(dynamic, use_web_fallback: bool) -> int:
@@ -108,7 +104,6 @@ async def process_dynamic_uid(uid: int):
108
104
  if retry_at is not None:
109
105
  if retry_at > monotonic():
110
106
  logger.debug(f"动态接口风控冷却中,跳过 {name}({uid})")
111
- await throttle_dynamic_loop()
112
107
  return
113
108
  del dynamic_risk_control_until[uid]
114
109
 
@@ -135,9 +130,7 @@ async def process_dynamic_uid(uid: int):
135
130
 
136
131
  dynamic_risk_control_until.pop(uid, None)
137
132
 
138
- if not dynamics: # 没发过动态
139
- if uid in offset and offset[uid] == -1: # 不记录会导致第一次发动态不推送
140
- await db.set_dynamic_offset(uid, 0)
133
+ if not dynamics:
141
134
  return
142
135
  name = get_dynamic_author_name(dynamics[0], use_web_fallback)
143
136
 
@@ -193,36 +186,46 @@ async def process_dynamic_uid(uid: int):
193
186
 
194
187
  async def dy_sched():
195
188
  """动态推送"""
196
- if not await db.wait_until_ready():
197
- logger.debug("数据库尚未初始化完成,跳过本轮动态推送")
198
- await throttle_dynamic_loop()
199
- return
189
+ try:
190
+ if not await db.wait_until_ready():
191
+ logger.debug("数据库尚未初始化完成,跳过本轮动态推送")
192
+ return
200
193
 
201
- uids = await db.get_uid_list("dynamic")
202
- if not uids:
203
- # 没有订阅先暂停一秒再跳过,不然会导致 CPU 占用过高
204
- await throttle_dynamic_loop()
205
- return
194
+ uids = await db.get_uid_list("dynamic")
195
+ if not uids:
196
+ return
206
197
 
207
- logger.debug(f"爬取动态列表,总共 {len(uids)} 人")
208
- semaphore = asyncio.Semaphore(DYNAMIC_FETCH_CONCURRENCY)
198
+ logger.debug(f"爬取动态列表,总共 {len(uids)} 人")
199
+ semaphore = asyncio.Semaphore(DYNAMIC_FETCH_CONCURRENCY)
209
200
 
210
- async def run_for_uid(uid: int):
211
- async with semaphore:
212
- await process_dynamic_uid(uid)
201
+ async def run_for_uid(uid: int):
202
+ async with semaphore:
203
+ await process_dynamic_uid(uid)
213
204
 
214
- await asyncio.gather(*(run_for_uid(uid) for uid in uids))
215
- await throttle_dynamic_loop()
205
+ await asyncio.gather(*(run_for_uid(uid) for uid in uids))
206
+ except asyncio.CancelledError:
207
+ logger.debug("动态推送任务已取消")
208
+
209
+
210
+ def schedule_next_dynamic_job(*, immediate: bool = False):
211
+ if scheduler.get_job("dynamic_sched"):
212
+ return
213
+ delay = timedelta(0) if immediate else timedelta(seconds=DYNAMIC_THROTTLE_SECONDS)
214
+ scheduler.add_job(
215
+ dy_sched,
216
+ id="dynamic_sched",
217
+ next_run_time=datetime.now(scheduler.timezone) + delay,
218
+ )
216
219
 
217
220
 
218
221
  def dynamic_lisener(event):
219
222
  if hasattr(event, "job_id") and event.job_id != "dynamic_sched":
220
223
  return
221
- job = scheduler.get_job("dynamic_sched")
222
- if not job:
223
- scheduler.add_job(
224
- dy_sched, id="dynamic_sched", next_run_time=datetime.now(scheduler.timezone)
225
- )
224
+ if event.code == EVENT_JOB_ERROR and isinstance(
225
+ getattr(event, "exception", None), asyncio.CancelledError
226
+ ):
227
+ logger.debug("动态推送任务已取消,将重新调度")
228
+ schedule_next_dynamic_job(immediate=event.code == EVENT_SCHEDULER_STARTED)
226
229
 
227
230
 
228
231
  if plugin_config.bililive_dynamic_interval == 0:
@@ -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.6"
3
+ __version__ = "2.1.8"
4
4
  VERSION = Version(__version__)
@@ -39,7 +39,7 @@ dependencies = [
39
39
  "msvc-runtime>=14.34.31931; sys_platform == \"win32\"",
40
40
  ]
41
41
  dynamic = []
42
- version = "2.1.6"
42
+ version = "2.1.8"
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 = {
@@ -268,6 +341,72 @@ class DBPermissionTests(unittest.IsolatedAsyncioTestCase):
268
341
  self.assertFalse(changed)
269
342
  update.assert_not_awaited()
270
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
+
271
410
 
272
411
  if __name__ == "__main__":
273
412
  unittest.main()